<?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: nosyos</title>
    <description>The latest articles on DEV Community by nosyos (@nosyos).</description>
    <link>https://dev.to/nosyos</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3846783%2Fd4b4c90d-028b-4858-b1bd-b5039dab28b5.jpg</url>
      <title>DEV Community: nosyos</title>
      <link>https://dev.to/nosyos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nosyos"/>
    <language>en</language>
    <item>
      <title>Streaming SSR Is Not a Free LCP Win</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Tue, 26 May 2026 14:01:00 +0000</pubDate>
      <link>https://dev.to/nosyos/streaming-ssr-is-not-a-free-lcp-win-5b8l</link>
      <guid>https://dev.to/nosyos/streaming-ssr-is-not-a-free-lcp-win-5b8l</guid>
      <description>&lt;p&gt;Streaming SSR sounds like an obvious win. Instead of waiting for all server-side work to finish before sending any HTML, Next.js starts sending the page immediately and streams the rest as it becomes ready. Users see content sooner. LCP improves.&lt;/p&gt;

&lt;p&gt;Except it doesn't always. The improvement depends entirely on where your LCP element lives in the component tree.&lt;/p&gt;




&lt;h2&gt;
  
  
  What streaming actually does
&lt;/h2&gt;

&lt;p&gt;Without streaming, the server waits until the full page is rendered before sending the first byte of HTML. With streaming, Next.js sends an initial shell immediately, then flushes additional HTML chunks as &lt;code&gt;Suspense&lt;/code&gt; boundaries resolve.&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="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;ProductPage&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="c1"&gt;// This data is needed for the shell — it blocks the initial response&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;category&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;getCategory&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;categoryId&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CategoryHeader&lt;/span&gt; &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* This Suspense boundary streams in separately */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductListSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductList&lt;/span&gt; &lt;span class="na"&gt;categoryId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;categoryId&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;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CategoryHeader&lt;/code&gt; is in the shell — it arrives with the first response. &lt;code&gt;ProductList&lt;/code&gt; streams in after its data resolves. The browser can start rendering and displaying the header while the product list is still loading on the server.&lt;/p&gt;

&lt;p&gt;This genuinely helps perceived performance. The page feels interactive earlier because something meaningful is visible sooner. Whether it helps LCP depends on what that "something" is.&lt;/p&gt;




&lt;h2&gt;
  
  
  LCP only improves if the LCP element is in the shell
&lt;/h2&gt;

&lt;p&gt;The browser measures LCP against when content actually appears on screen. If your LCP element — the hero image, the main heading, the product photo — is inside a &lt;code&gt;Suspense&lt;/code&gt; boundary, the browser can't render it until that chunk streams in. Streaming didn't make it arrive faster. It just deferred other content.&lt;/p&gt;

&lt;p&gt;I added &lt;code&gt;Suspense&lt;/code&gt; boundaries to a product page expecting LCP to improve. It got worse by 400ms. The hero image was inside the product &lt;code&gt;Suspense&lt;/code&gt; boundary, waiting for the same data that was blocking the page before. The browser couldn't start fetching the image until the streamed chunk arrived with the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag. Streaming had delayed the only thing that mattered for LCP.&lt;/p&gt;

&lt;p&gt;The rule is straightforward: the LCP element must be in the initial shell, or in a &lt;code&gt;Suspense&lt;/code&gt; boundary that resolves before anything else. Everything else can stream in afterward.&lt;/p&gt;




&lt;h2&gt;
  
  
  Suspense boundary placement is the whole game
&lt;/h2&gt;

&lt;p&gt;Designing a streaming page means deciding what goes in the shell and what gets deferred. The shell should contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The LCP element — always&lt;/li&gt;
&lt;li&gt;Navigation and layout structure&lt;/li&gt;
&lt;li&gt;Above-the-fold content that's cheap to fetch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything that's slow to fetch, personalized, or below the fold is a candidate for a &lt;code&gt;Suspense&lt;/code&gt; boundary.&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="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;ProductPage&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="c1"&gt;// Fast query — product basics needed for LCP&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&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;getProductBasics&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;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Hero image is in the shell — LCP element renders immediately */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductHero&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heroImage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Slow queries — personalized, below the fold */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReviewsSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReviewSection&lt;/span&gt; &lt;span class="na"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;RecommendationsSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PersonalizedRecommendations&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;userId&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;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getProductBasics&lt;/code&gt; fetches only what the shell needs — title, hero image, price. The slow queries for reviews and personalized recommendations happen in parallel behind their &lt;code&gt;Suspense&lt;/code&gt; boundaries. The hero image is in the shell, so the browser can start loading it with the first response.&lt;/p&gt;

&lt;p&gt;This requires splitting what was probably one database query into two: a fast query for above-the-fold data and a deferred query for everything else. That's the real cost of streaming — not technical complexity, but discipline about which data the shell actually needs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Preloading resources before they stream
&lt;/h2&gt;

&lt;p&gt;A hero image inside the shell still has to be fetched after the HTML arrives. On slow connections, that fetch adds meaningfully to LCP.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; tag in the shell tells the browser to start fetching the image immediately, in parallel with parsing the rest of the HTML:&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="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;ProductPage&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;product&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;getProductBasics&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;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;link&lt;/span&gt;
        &lt;span class="na"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt;
        &lt;span class="na"&gt;as&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"image"&lt;/span&gt;
        &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heroImage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;fetchpriority&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductHero&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heroImage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;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;Next.js flushes &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tags in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; before the body content. The browser sees the preload hint before it encounters the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag in the shell, so the fetch starts earlier. For large images on slow connections, this can move LCP by several hundred milliseconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Measuring LCP on streaming pages
&lt;/h2&gt;

&lt;p&gt;Lighthouse doesn't simulate streaming. It loads the page and measures LCP against the fully rendered result, which means it can't tell you whether your shell is arriving fast or whether streaming is actually helping.&lt;/p&gt;

&lt;p&gt;Field data from real users is the only measurement that captures streaming behavior. A streaming page where the shell is fast will show LCP concentrated at a low value in the distribution. A streaming page where the LCP element was accidentally put inside a &lt;code&gt;Suspense&lt;/code&gt; boundary will show LCP spread across a wide range — fast for users with low server latency, slow for everyone else.&lt;/p&gt;

&lt;p&gt;Watching LCP in production after changes to Suspense boundaries is essential. A boundary that seemed like an obvious improvement in development — where all database queries complete in under 5ms — can significantly delay LCP in production where the queries take 80ms.&lt;/p&gt;

&lt;p&gt;LCP regressions from streaming changes are subtle. They don't always show up in aggregate metrics immediately, because the regression often only affects users on slower connections or in regions with higher server latency. &lt;a href="https://rpalert.dev" rel="noopener noreferrer"&gt;RPAlert&lt;/a&gt; alerts on LCP threshold crossings from real browsers within 60 seconds — the kind of segmented, real-user signal that catches these regressions before they appear in a weekly CrUX report. Given that streaming bugs tend to be environment-specific, having a production alert is the difference between catching a regression on Tuesday and noticing it the following Monday.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mental model that makes streaming work
&lt;/h2&gt;

&lt;p&gt;Streaming is a tool for getting the right content to the browser at the right time. The shell should be fast and contain what users immediately see. &lt;code&gt;Suspense&lt;/code&gt; boundaries should contain what's slow, personalized, or below the fold.&lt;/p&gt;

&lt;p&gt;When that split is done correctly, streaming genuinely improves perceived performance and often improves LCP. When the LCP element ends up on the wrong side of a &lt;code&gt;Suspense&lt;/code&gt; boundary, streaming adds latency without improving anything the user notices.&lt;/p&gt;

&lt;p&gt;The questions to ask before adding a &lt;code&gt;Suspense&lt;/code&gt; boundary: what is the LCP element on this page, and is it going to be in the shell? If not, restructure the data fetching before restructuring the component tree.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>React Server Components Don't Make Your App Fast by Default</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Thu, 21 May 2026 14:05:00 +0000</pubDate>
      <link>https://dev.to/nosyos/react-server-components-dont-make-your-app-fast-by-default-3442</link>
      <guid>https://dev.to/nosyos/react-server-components-dont-make-your-app-fast-by-default-3442</guid>
      <description>&lt;p&gt;A Next.js app migrated from Pages Router to App Router. RSC throughout, &lt;code&gt;use client&lt;/code&gt; pushed to the leaves. Lighthouse score went up. LCP in production got worse by 800ms.&lt;/p&gt;

&lt;p&gt;The component tree was fetching data. Each server component called its own database query. They ran sequentially — one couldn't start until its parent finished. What was previously one &lt;code&gt;getServerSideProps&lt;/code&gt; call became eight round trips before the page could render.&lt;/p&gt;

&lt;p&gt;RSC reduces client JavaScript. That part works exactly as advertised. What it doesn't do is make data fetching faster, or eliminate the server-side work that now determines when your HTML actually arrives.&lt;/p&gt;




&lt;h2&gt;
  
  
  What RSC actually changes
&lt;/h2&gt;

&lt;p&gt;Server components stay on the server. Their code never ships to the browser, they don't get hydrated, and anything they import doesn't touch the client bundle. For components that are genuinely static — content that renders from data and never responds to user interaction — this is a real win. The JavaScript savings from the previous article compound here: pushing &lt;code&gt;use client&lt;/code&gt; to the leaves is how you maximize the RSC benefit.&lt;/p&gt;

&lt;p&gt;Bundle size reduction is real. But LCP is determined by when the HTML arrives, not by how much JavaScript the client downloads afterward. If server components are slow, TTFB is slow, and LCP is slow — regardless of how small the client bundle is.&lt;/p&gt;




&lt;h2&gt;
  
  
  The waterfall that App Router makes easy to create
&lt;/h2&gt;

&lt;p&gt;Pages Router's data fetching model had a structural advantage it rarely got credit for: &lt;code&gt;getServerSideProps&lt;/code&gt; ran once, in one place, before the page rendered. Everything the page needed came from one function.&lt;/p&gt;

&lt;p&gt;With RSC, each component can fetch its own data. This sounds cleaner — components are self-contained, colocated with their data requirements. The problem is that React renders the component tree top to bottom, and a child component's data fetch can't start until its parent has rendered and the child has mounted on the server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ParentComponent fetches its data first&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;ParentComponent&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;parent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM parent WHERE id = ?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;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;ChildComponent&lt;/span&gt; &lt;span class="na"&gt;parentId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;span class="c1"&gt;// ChildComponent can't start until ParentComponent finishes&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;ChildComponent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;parentId&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;children&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM children WHERE parent_id = ?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;parentId&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;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* render */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two queries, sequential. If each takes 40ms, that's 80ms added to TTFB before the waterfall. Nest three or four levels of components that each need data, and you're looking at 200–400ms of sequential database round trips added to every page render.&lt;/p&gt;

&lt;p&gt;This is the App Router waterfall. It's easy to create, easy to miss in development where database latency is near zero, and immediately visible in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fetching in parallel
&lt;/h2&gt;

&lt;p&gt;The fix is to hoist data fetching to the component that needs all the data, or to run independent queries in parallel:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;productId&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// These run in parallel — Promise.all starts both immediately&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;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductHeader&lt;/span&gt; &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InventoryStatus&lt;/span&gt; &lt;span class="na"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;inventory&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReviewList&lt;/span&gt; &lt;span class="na"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;reviews&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;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three queries, one round trip's worth of latency. The child components receive data as props and render without fetching anything themselves.&lt;/p&gt;

&lt;p&gt;This trades component colocation for speed. Sometimes that's the right tradeoff. For a page where TTFB matters — which is most pages — it usually is.&lt;/p&gt;

&lt;p&gt;For queries that genuinely can't be parallelized because they depend on each other's results, &lt;code&gt;React.cache&lt;/code&gt; at least prevents duplicate fetches across a single render when multiple components request the same data:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&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;getUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cache&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="nx"&gt;userId&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Both components call getUser — only one database query executes&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;Header&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&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;user&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;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&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;Sidebar&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&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;user&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;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;aside&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;aside&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cache&lt;/code&gt; deduplicates within a single request. It doesn't cache across requests — that's what unstable_cache or a proper caching layer handles.&lt;/p&gt;




&lt;h2&gt;
  
  
  TTFB is the new LCP bottleneck
&lt;/h2&gt;

&lt;p&gt;With Pages Router, slow &lt;code&gt;getServerSideProps&lt;/code&gt; was obviously the bottleneck because it was the one place data fetching happened. With App Router, the bottleneck is distributed across the component tree and harder to trace.&lt;/p&gt;

&lt;p&gt;The signal to look for: LCP correlates with server response time in your field data, not with client-side metrics. If p75 LCP improves when you filter to users with fast connections, that's a client-side problem. If it's high regardless of connection speed, the server is slow.&lt;/p&gt;

&lt;p&gt;Measuring TTFB separately from LCP in your RUM data makes this visible. If TTFB is 600ms and LCP is 650ms, the client is fine — the server is the problem.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entryType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;sendMetric&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;ttfb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseStart&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tracking TTFB per page in production shows which routes have server-side performance problems. A product listing page with high TTFB is a data fetching problem. A marketing page with high TTFB is a different problem — probably server cold starts or lack of caching.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where RSC genuinely helps
&lt;/h2&gt;

&lt;p&gt;RSC is the right model for components that render server-side data without needing client interactivity. Static product descriptions, server-rendered markdown, navigation built from a CMS — these belong on the server. Moving them there reduces the client bundle and eliminates hydration cost, and that's a real performance improvement.&lt;/p&gt;

&lt;p&gt;What RSC doesn't do is make database queries faster, fix slow APIs, or reduce TTFB if your data fetching is sequential. The performance benefit of RSC is realized at the component boundary — it removes cost on the client. Everything upstream of that boundary is still your problem.&lt;/p&gt;

&lt;p&gt;The next article in this series covers streaming SSR — the actual mechanism that lets Next.js send HTML before all the server-side data is ready, and where it genuinely helps LCP versus where it creates new problems to manage.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Most of Your Client JavaScript Exists to Hydrate Pages Users Already See</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Tue, 19 May 2026 14:06:00 +0000</pubDate>
      <link>https://dev.to/nosyos/most-of-your-client-javascript-exists-to-hydrate-pages-users-already-see-2pdf</link>
      <guid>https://dev.to/nosyos/most-of-your-client-javascript-exists-to-hydrate-pages-users-already-see-2pdf</guid>
      <description>&lt;p&gt;There's a window between when a Next.js page finishes painting and when users can actually interact with it. Buttons look clickable. Links look followable. Nothing responds. The page is there — the HTML arrived, the browser rendered it — but the JavaScript hasn't finished running yet.&lt;/p&gt;

&lt;p&gt;That window is hydration. And most of the JavaScript your app sends to the browser exists to get through it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What hydration costs
&lt;/h2&gt;

&lt;p&gt;The server renders HTML and sends it. The browser paints it immediately — that's your LCP. Then the browser downloads your JS bundle, parses it, compiles it, and runs it. React walks the entire DOM, compares it to the virtual DOM, and attaches event listeners to every interactive element. Only then is the page actually usable.&lt;/p&gt;

&lt;p&gt;The time between LCP and Time to Interactive is the hydration tax. On a fast connection and a modern laptop it's barely noticeable. On a mid-range Android phone on 4G, it can be two to four seconds.&lt;/p&gt;

&lt;p&gt;To see your own numbers: in Chrome DevTools Performance panel, record a page load and find the gap between the LCP marker and the point where long tasks stop. That gap is roughly what hydration is costing your users.&lt;/p&gt;




&lt;h2&gt;
  
  
  The &lt;code&gt;use client&lt;/code&gt; boundary problem
&lt;/h2&gt;

&lt;p&gt;Next.js App Router lets you keep components on the server by default. The moment you add &lt;code&gt;use client&lt;/code&gt;, that component and everything it imports gets bundled for the client and hydrated.&lt;/p&gt;

&lt;p&gt;The mistake I see most often is adding &lt;code&gt;use client&lt;/code&gt; to a layout or wrapper component because one child needs 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="c1"&gt;// This ships the entire subtree to the client&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Header&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./Header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StaticContent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./StaticContent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// doesn't need to be a client component&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;InteractiveWidget&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./InteractiveWidget&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// this is the only reason for 'use client'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PageLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StaticContent&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InteractiveWidget&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;StaticContent&lt;/code&gt; and &lt;code&gt;Header&lt;/code&gt; are now client components. They'll be included in the JS bundle and hydrated, even if they never use state, effects, or event handlers.&lt;/p&gt;

&lt;p&gt;The fix is to push &lt;code&gt;use client&lt;/code&gt; as far down the tree as possible — to the component that actually needs 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="c1"&gt;// Server component — no bundle cost, no hydration&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Header&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./Header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StaticContent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./StaticContent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;InteractiveWidget&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./InteractiveWidget&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 'use client' lives inside this file&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PageLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StaticContent&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InteractiveWidget&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;InteractiveWidget&lt;/code&gt; still ships to the client because it needs to be interactive. But &lt;code&gt;Header&lt;/code&gt; and &lt;code&gt;StaticContent&lt;/code&gt; stay on the server. Their HTML arrives pre-rendered and nothing hydrates them.&lt;/p&gt;

&lt;p&gt;This is the single highest-leverage change in most Next.js App Router migrations I've seen. Auditing &lt;code&gt;use client&lt;/code&gt; placement before reaching for any other optimization is worth doing first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding what's actually in your bundle
&lt;/h2&gt;

&lt;p&gt;The Next.js bundle analyzer shows what's in each chunk. Add it to &lt;code&gt;next.config.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;withBundleAnalyzer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@next/bundle-analyzer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)({&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ANALYZE&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withBundleAnalyzer&lt;/span&gt;&lt;span class="p"&gt;({});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;ANALYZE=true npm run build&lt;/code&gt; and look at the client bundle. Two things to look for: large dependencies that are imported inside &lt;code&gt;use client&lt;/code&gt; components but don't actually need browser APIs, and server utilities that accidentally got pulled into the client graph.&lt;/p&gt;

&lt;p&gt;A date formatting library that ended up in the client bundle because it was imported in a &lt;code&gt;use client&lt;/code&gt; component that could have been a server component — I've found this more than once. Moving the component to the server removes the library from the client bundle entirely.&lt;/p&gt;

&lt;p&gt;Code splitting reduces bundle size, but it doesn't help if what you're splitting is still fully hydrated. Smaller chunks load faster; that's real. But if every chunk is loaded and hydrated on every page, the gain is limited.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deferring hydration for below-the-fold content
&lt;/h2&gt;

&lt;p&gt;Content the user can't see immediately doesn't need to be hydrated immediately. React's &lt;code&gt;lazy&lt;/code&gt; with &lt;code&gt;Suspense&lt;/code&gt; defers both the download and the hydration:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Suspense&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&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;HeavyDashboardWidget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./HeavyDashboardWidget&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Dashboard&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AboveTheFoldContent&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;WidgetSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;HeavyDashboardWidget&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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 widget's JavaScript isn't downloaded or hydrated until React renders that &lt;code&gt;Suspense&lt;/code&gt; boundary. For content far down the page, wrapping it in a lazy-loaded component with an &lt;code&gt;IntersectionObserver&lt;/code&gt; trigger defers hydration until it's actually scrolled into view — though this adds complexity that's only worth it for genuinely heavy components.&lt;/p&gt;

&lt;p&gt;The simpler version — just using &lt;code&gt;lazy&lt;/code&gt; without the intersection observer — already helps because the browser prioritizes loading and hydrating above-the-fold content first.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this looks like in production
&lt;/h2&gt;

&lt;p&gt;LCP and TTI moving in opposite directions is a signal that hydration cost has gotten out of hand. LCP improves because you're serving fast HTML from the server. TTI stays high because you're still sending a large client bundle to hydrate it.&lt;/p&gt;

&lt;p&gt;Measuring LCP is straightforward. TTI is harder — it's not a Core Web Vital and it doesn't appear in CrUX. The proxy metric is INP on early interactions: if users are clicking buttons within the first few seconds of loading and seeing high INP, the page probably wasn't fully hydrated yet when they tried.&lt;/p&gt;

&lt;p&gt;The next article looks at what Server Components actually change about the bundle and hydration picture — and where they don't help as much as the marketing suggests.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What Field Data Tells You That Lighthouse Can't</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Thu, 14 May 2026 14:02:00 +0000</pubDate>
      <link>https://dev.to/nosyos/what-field-data-tells-you-that-lighthouse-cant-1gc</link>
      <guid>https://dev.to/nosyos/what-field-data-tells-you-that-lighthouse-cant-1gc</guid>
      <description>&lt;p&gt;A Lighthouse 95 on a marketing page. CrUX showing 68% of real users experiencing "Poor" LCP. Both measuring the same URL.&lt;/p&gt;

&lt;p&gt;The page was getting most of its traffic from users in Southeast Asia on mid-range Android devices over 4G. Lighthouse was running on a simulated Moto G4, but with a fast local network and a warm server. The simulated device wasn't the problem — the network conditions and server geography were.&lt;/p&gt;

&lt;p&gt;This is the gap that field data closes. Lighthouse tells you what's possible in a controlled environment. Field data tells you what's actually happening.&lt;/p&gt;




&lt;h2&gt;
  
  
  Start with what you already have
&lt;/h2&gt;

&lt;p&gt;Before building anything, check the Chrome UX Report. PageSpeed Insights shows CrUX data for any URL with enough traffic — real-user LCP, INP, and CLS distributions, broken down into Good / Needs Improvement / Poor buckets. Google Search Console's Core Web Vitals report shows the same data aggregated by page group.&lt;/p&gt;

&lt;p&gt;CrUX has two limitations worth knowing. It only includes URLs that Chrome users have visited with usage reporting enabled, so low-traffic pages won't appear. And it's aggregated over a 28-day rolling window, which means a regression you shipped last week might not fully show up for another three weeks.&lt;/p&gt;

&lt;p&gt;For most teams, CrUX is the right first stop. If your CrUX data looks fine, synthetic testing is probably sufficient. If it doesn't — or if you don't have enough traffic to appear in CrUX — you need your own RUM pipeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  The web-vitals library is the right foundation
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;web-vitals&lt;/code&gt; library handles the attribution and calculation details that the raw PerformanceObserver API leaves to you. It reports the same values Google uses for CrUX, which matters when you're trying to correlate your internal data with what's affecting your search ranking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onLCP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onINP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onCLS&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;web-vitals/attribution&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendToAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/vitals&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&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;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metric&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="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// 'good' | 'needs-improvement' | 'poor'&lt;/span&gt;
      &lt;span class="na"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;navigationType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;navigationType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;keepalive&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="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;onLCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sendToAnalytics&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;onINP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sendToAnalytics&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;onCLS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sendToAnalytics&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/attribution&lt;/code&gt; import is the important part. &lt;code&gt;metric.attribution&lt;/code&gt; for INP includes the interaction target, the event type, and the three-phase breakdown — input delay, processing time, presentation delay — that tells you where the time was lost. For CLS, it includes the element that shifted and how far. This attribution data is what makes field measurements actionable rather than just decorative.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;keepalive: true&lt;/code&gt; on the fetch ensures the request completes even if the user navigates away before it finishes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Segment before you draw conclusions
&lt;/h2&gt;

&lt;p&gt;Raw averages hide almost everything. A p75 LCP of 2.4 seconds looks fine — until you split by device type and see that desktop users are at 1.1s and mobile users are at 3.8s. Or split by page and find that 90% of the bad INP scores are concentrated on one product listing page.&lt;/p&gt;

&lt;p&gt;The minimum useful dimensions to capture with every metric:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Page path (not the full URL — strip query strings and dynamic segments)&lt;/li&gt;
&lt;li&gt;Connection type (&lt;code&gt;navigator.connection?.effectiveType&lt;/code&gt; — gives you "4g", "3g", "2g", "slow-2g")&lt;/li&gt;
&lt;li&gt;Device memory (&lt;code&gt;navigator.deviceMemory&lt;/code&gt; — rough bucketing into low/mid/high)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getDeviceContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;effectiveType&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;deviceMemory&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;deviceMemory&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;navigator.connection&lt;/code&gt; and &lt;code&gt;navigator.deviceMemory&lt;/code&gt; are Chrome-only and non-standard, but they're available for the large share of your users on Chrome where the data is most useful. Treat &lt;code&gt;unknown&lt;/code&gt; as a valid bucket rather than an error.&lt;/p&gt;

&lt;p&gt;With connection type as a dimension, "mobile users have worse LCP" becomes "mobile users on 3G have worse LCP, but mobile users on 4G are on par with desktop" — and that's a different optimization problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  The metrics you're not collecting yet
&lt;/h2&gt;

&lt;p&gt;INP is reported once per page session — the worst interaction in the session. That's the metric Google uses, but for debugging you want to know which specific interactions are bad, not just the worst one.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;web-vitals&lt;/code&gt; library's &lt;code&gt;onINP&lt;/code&gt; callback includes attribution for the worst interaction. For catching the long tail, collecting individual slow interaction events separately is more useful:&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="nf"&gt;onINP&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;metric&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="nf"&gt;sendToAnalytics&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// attribution.interactionTarget is the CSS selector of the element&lt;/span&gt;
    &lt;span class="na"&gt;interactionTarget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interactionTarget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;interactionType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interactionType&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;&lt;code&gt;interactionTarget&lt;/code&gt; gives you the CSS selector of the element the user interacted with. When you see the same selector appearing repeatedly in your slow INP data, you've found the component to fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  From measurement to alerting
&lt;/h2&gt;

&lt;p&gt;Collecting data answers "how bad is it." Alerting answers "when did it get worse."&lt;/p&gt;

&lt;p&gt;A performance regression that appears in your RUM data on Tuesday might not surface in a weekly review until Friday. By then it's been affecting real users for three days and the deployment that caused it is buried under subsequent changes.&lt;/p&gt;

&lt;p&gt;The gap between measurement and alerting is where most RUM setups fall short. Teams collect the data, build a dashboard, and check it periodically. They're doing monitoring — looking at history — rather than alerting.&lt;/p&gt;

&lt;p&gt;Setting up threshold alerts on your p75 values — LCP over 2.5s, INP over 200ms, CLS over 0.1 — against a rolling window of real user data catches regressions close to when they happen. If you'd rather not build and maintain that alerting layer, &lt;a href="https://rpalert.dev" rel="noopener noreferrer"&gt;RPAlert&lt;/a&gt; handles it for React apps: it collects Web Vitals from real browsers and sends a Slack or Discord notification within 60 seconds of a threshold crossing. The free tier covers one app, which is enough to see whether the alerting model is useful before committing to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to do with the data
&lt;/h2&gt;

&lt;p&gt;Field data changes the sequence of performance work.&lt;/p&gt;

&lt;p&gt;Without it: pick metrics to optimize based on Lighthouse scores and engineering intuition, ship improvements, re-run Lighthouse, hope.&lt;/p&gt;

&lt;p&gt;With it: look at where real users are experiencing the worst scores, segment to find the specific pages and conditions, fix those, watch the field data improve.&lt;/p&gt;

&lt;p&gt;The third article in this series noted that CLS is slow to debug because the shifts depend on network timing and server conditions that don't exist locally. The same is true for INP on low-memory devices and LCP on slow connections. Lab data can't replicate those conditions reliably. Field data is the only measurement that captures them.&lt;/p&gt;

&lt;p&gt;Running the &lt;code&gt;web-vitals&lt;/code&gt; instrumentation in production for a week generates enough data to prioritize the rest of the work in this series. That's where to start.&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why CLS Is Harder to Fix in React Than You Think</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Tue, 12 May 2026 14:14:00 +0000</pubDate>
      <link>https://dev.to/nosyos/why-cls-is-harder-to-fix-in-react-than-you-think-47i1</link>
      <guid>https://dev.to/nosyos/why-cls-is-harder-to-fix-in-react-than-you-think-47i1</guid>
      <description>&lt;p&gt;CLS is the only Core Web Vital that tends to get worse as a product matures. Every component you lazy-load, every feature flag that conditionally renders content, every third-party widget you add — each one is a potential layout shift. LCP and INP can be improved incrementally. CLS requires you to think about rendering order before you write the component.&lt;/p&gt;

&lt;p&gt;That's what makes it frustrating. The fix is usually architectural, not a one-line change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why CLS behaves differently
&lt;/h2&gt;

&lt;p&gt;LCP and INP have clear causes you can trace in DevTools. CLS shifts can happen at any point during a page's life, from any element, triggered by content the browser wasn't expecting to have to reflow around.&lt;/p&gt;

&lt;p&gt;The browser assigns each shift a score based on the fraction of the viewport that moved and how far it moved. Shifts that happen within 500ms of a user interaction don't count — those are expected. Everything else does.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; on images is table stakes. It helps, but it's not where React apps usually fail CLS.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hydration layout shift
&lt;/h2&gt;

&lt;p&gt;This is the one that catches Next.js developers off guard.&lt;/p&gt;

&lt;p&gt;Server-rendered HTML is delivered with one set of dimensions and content. React hydrates on the client, and if the hydrated output differs from the server HTML — even slightly — the browser has to reflow. That reflow is a layout shift.&lt;/p&gt;

&lt;p&gt;The most common trigger is content that depends on something only available in the browser: &lt;code&gt;window.innerWidth&lt;/code&gt;, &lt;code&gt;localStorage&lt;/code&gt;, a cookie, a user preference stored in context. If a component conditionally renders based on any of these, the server renders one version and the client immediately replaces 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="c1"&gt;// This causes a hydration layout shift every time&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Sidebar&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isExpanded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsExpanded&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&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="s1"&gt;sidebar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expanded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;// server always renders collapsed&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;aside&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isExpanded&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;280&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&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;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;aside&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server renders a 64px sidebar. The client immediately expands it to 280px. Everything to the right shifts left by 216px. CLS score spikes.&lt;/p&gt;

&lt;p&gt;The fix is to defer that client-only render until after hydration, so the shift doesn't happen against already-painted content:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Sidebar&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;mounted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setMounted&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isExpanded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsExpanded&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="nf"&gt;setIsExpanded&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="s1"&gt;sidebar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expanded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setMounted&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="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="c1"&gt;// Render a placeholder with fixed dimensions until mounted&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;mounted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;aside&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;aria-hidden&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;aside&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isExpanded&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;280&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&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;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;aside&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The placeholder has the same dimensions as the default state. The shift from 64px to 280px happens after &lt;code&gt;useEffect&lt;/code&gt;, which means it happens after paint — and the browser waits for user interaction before that can affect CLS meaningfully.&lt;/p&gt;

&lt;p&gt;The broader principle: any component that renders differently on server vs client needs a stable placeholder with matching dimensions. If you can't give it stable dimensions, defer it entirely with &lt;code&gt;dynamic(() =&amp;gt; import('./Component'), { ssr: false })&lt;/code&gt; in Next.js.&lt;/p&gt;




&lt;h2&gt;
  
  
  Font loading reflow
&lt;/h2&gt;

&lt;p&gt;A font swap causes a layout shift when the fallback font and the web font have different metrics — different character widths, line heights, or letter spacing. The text reflows to fit the new font, and anything below it shifts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;font-display: swap&lt;/code&gt; is often recommended because it avoids invisible text. The tradeoff is exactly this: visible text that then shifts. For body text at small sizes the shift is usually small enough to ignore. For large headings, a font swap can move substantial content.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;size-adjust&lt;/code&gt; closes most of the gap by scaling the fallback font to match the web font's metrics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@font-face&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;'CustomFont'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sx"&gt;url('/fonts/custom.woff2')&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;'woff2'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;font-display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;swap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@font-face&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;'CustomFontFallback'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;'Arial'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;size-adjust&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;104%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;ascent-override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;90%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;descent-override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;line-gap-override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting the values right requires some trial and error against your specific font. The &lt;a href="https://github.com/unjs/fontaine" rel="noopener noreferrer"&gt;fontaine&lt;/a&gt; library automates this for Next.js and Nuxt apps — it generates the adjusted fallback declarations based on the actual font metrics. Next.js 13+ does this automatically for Google Fonts loaded via &lt;code&gt;next/font&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you're not using &lt;code&gt;next/font&lt;/code&gt; for custom fonts, you're probably generating more CLS from font swaps than you realize.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dynamic content that pushes existing content
&lt;/h2&gt;

&lt;p&gt;Notifications, cookie banners, chat widgets, promotional bars — anything that inserts itself above or around existing content after the initial render is a layout shift.&lt;/p&gt;

&lt;p&gt;The browser doesn't penalize shifts that happen within 500ms of a user interaction, but it does penalize shifts triggered by data loading, lazy component mounting, or timeouts. A toast notification that appears 2 seconds after page load and pushes content down by 60px is a CLS event.&lt;/p&gt;

&lt;p&gt;The consistent fix is to reserve space before the content loads:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;NotificationBanner&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;notification&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useNotification&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Reserve 60px whether or not there's a notification&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;minHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&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;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Banner&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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 alternative — animating new content in using &lt;code&gt;transform&lt;/code&gt; instead of position changes — avoids CLS entirely because transforms don't affect layout. A banner that slides in from off-screen doesn't move existing content.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Shifts content — CLS event&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Banner&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;relative&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;showBanner&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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;60&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;span class="c1"&gt;// Doesn't shift content — no CLS&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Banner&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`translateY(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;showBanner&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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px)`&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 &lt;code&gt;transform&lt;/code&gt; version renders the element in document flow at its full height from the start, so no other element ever has to reflow around it appearing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Skeleton screens done wrong
&lt;/h2&gt;

&lt;p&gt;Skeleton screens are supposed to prevent CLS by holding space for incoming content. They cause CLS when the skeleton dimensions don't match the content that replaces them.&lt;/p&gt;

&lt;p&gt;A skeleton that renders a single line for what turns out to be a three-line text block. A card placeholder that's 180px tall for a card that renders at 220px. A list skeleton that shows five items when the API returns eight.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This skeleton will cause a shift if the actual card is taller&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CardSkeleton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#eee&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;borderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&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;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Better: match the content structure, not just the height&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CardSkeleton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&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;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;height&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;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;60%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#eee&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginBottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&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;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;90%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#eee&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginBottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;75%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#eee&lt;/span&gt;&lt;span class="dl"&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;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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 version matches the structural shape of the content, not just its total height. When the real card renders, the reflow is minimal because the block-level structure is already close to correct.&lt;/p&gt;

&lt;p&gt;Dynamic-length content — variable-height text, user-generated content, lists without a fixed item count — is genuinely hard to skeleton correctly. In those cases, the better tradeoff is often SSR with &lt;code&gt;Suspense&lt;/code&gt; streaming so the content arrives already rendered, rather than trying to skeleton something you can't predict.&lt;/p&gt;




&lt;h2&gt;
  
  
  Measuring which elements are causing shifts
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;layout-shift&lt;/code&gt; entry type in &lt;code&gt;PerformanceObserver&lt;/code&gt; gives you the shifted elements and their contribution to the score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hadRecentInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// skip user-triggered shifts&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;nodeName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;previousRect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;previousRect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;currentRect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentRect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;layout-shift&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;source.node&lt;/code&gt; is the actual DOM element that shifted. &lt;code&gt;previousRect&lt;/code&gt; and &lt;code&gt;currentRect&lt;/code&gt; show where it was and where it ended up. In practice, a few elements account for most of the CLS score — this surfaces them without needing to reproduce the shift in DevTools.&lt;/p&gt;

&lt;p&gt;Running this in production for a few days will tell you which components are responsible and which pages are most affected. The shifts that happen consistently on high-traffic pages are the ones worth fixing first.&lt;/p&gt;




&lt;p&gt;CLS is slow to debug because shifts often depend on network timing, font loading, and server response — conditions that don't exist in local development. Measuring from real users is the only reliable way to know where your score is actually coming from.&lt;/p&gt;

&lt;p&gt;The next article in this series covers field data collection in depth: what RUM gives you that Lighthouse can't, and how to build a measurement pipeline that makes debugging CLS, INP, and LCP from production tractable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Community patterns from production
&lt;/h2&gt;

&lt;p&gt;A few additional patterns from the comments, all caught with PerformanceObserver:&lt;br&gt;
Bilingual length variance — Russian content runs ~25–30% longer than English on average, causing EN single-line strings to wrap in RU. Rendering layout calculations against the longer-language baseline eliminates the locale-toggle shift.&lt;br&gt;
YouTube embeds — Reserve aspect ratio before the iframe loads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;css&lt;/span&gt;&lt;span class="nc"&gt;.youtube-embed-wrapper&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;56.25%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.youtube-embed-wrapper&lt;/span&gt; &lt;span class="nt"&gt;iframe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&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;Translation cache hydration — If translations are lazy-loaded on locale toggle, warm the cache eagerly on page load instead. The first fetch on toggle is where the shift lands.&lt;br&gt;
— via &lt;a class="mentioned-user" href="https://dev.to/arvavit"&gt;@arvavit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>INP Is Not Just a Faster FID</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Thu, 07 May 2026 14:28:00 +0000</pubDate>
      <link>https://dev.to/nosyos/inp-is-not-just-a-faster-fid-2cg5</link>
      <guid>https://dev.to/nosyos/inp-is-not-just-a-faster-fid-2cg5</guid>
      <description>&lt;p&gt;I once spent an afternoon cutting a click handler from 80ms down to 18ms. Clean, fast, properly debounced. The INP score didn't move.&lt;/p&gt;

&lt;p&gt;The handler wasn't the problem. The browser couldn't even start the handler for 190ms after the click — a long task was running at exactly the wrong moment. All that optimization was irrelevant.&lt;/p&gt;

&lt;p&gt;INP splits an interaction into three phases. Most React developers know one of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three-phase model
&lt;/h2&gt;

&lt;p&gt;Every INP interaction goes through:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Input delay&lt;/strong&gt; — the time from when the user interacts to when the browser can start processing the event. This is blocked by whatever is already running on the main thread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing time&lt;/strong&gt; — the time your event handlers actually execute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presentation delay&lt;/strong&gt; — the time from when handlers finish to when the browser renders the visual update. This is where React's reconciliation and paint happen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The total of these three is what INP measures for each interaction. The worst interaction in a session becomes the score.&lt;/p&gt;

&lt;p&gt;Optimizing processing time — the only phase most developers think about — is the right move when processing time is actually the bottleneck. It often isn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  Input delay is the one that surprises people
&lt;/h2&gt;

&lt;p&gt;Input delay is not caused by your handler. It's caused by whatever was running on the main thread the moment the user clicked.&lt;/p&gt;

&lt;p&gt;A React app rendering a large list after a search query completes. A &lt;code&gt;useEffect&lt;/code&gt; running a synchronous calculation on new data. A timer callback that scheduled itself to run every few seconds and happens to fire during an interaction. Any of these can generate 100–300ms of input delay that has nothing to do with the click handler the user triggered.&lt;/p&gt;

&lt;p&gt;The Chrome UX Report attribution for a slow INP event will tell you which phase is taking the most time. If input delay is more than 50ms, handler optimization is the wrong direction.&lt;/p&gt;

&lt;p&gt;To see the breakdown in your own data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entryType&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;interaction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&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="na"&gt;inputDelay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processingStart&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;processingTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processingEnd&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processingStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;presentationDelay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processingEnd&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;durationThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this for a week on production. Look at the breakdown. A high &lt;code&gt;inputDelay&lt;/code&gt; on a specific page tells you there's a long task running during that page's normal usage cycle — and that's the thing to fix, not the handler.&lt;/p&gt;




&lt;h2&gt;
  
  
  Breaking up the tasks that cause input delay
&lt;/h2&gt;

&lt;p&gt;The fix for input delay is reducing the size of long tasks so the browser has gaps to process input.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scheduler.yield()&lt;/code&gt; is the cleanest way to do this. It pauses execution and lets the browser handle any pending input before continuing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processLargeDataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Item&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;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Result&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;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;items&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="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;results&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="nf"&gt;expensiveTransform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&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="c1"&gt;// Every 50 items, yield to let the browser breathe&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;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;50&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="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;yield&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;return&lt;/span&gt; &lt;span class="nx"&gt;results&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;Without the yield, a 1,000-item dataset processed in one shot becomes a multi-hundred-millisecond long task that blocks input. With it, the browser gets a chance to handle clicks between chunks. INP stays low even while the work is ongoing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scheduler.yield()&lt;/code&gt; is available in Chrome and Edge. For broader support, &lt;code&gt;setTimeout(0)&lt;/code&gt; works as a fallback, though the browser may batch it less aggressively.&lt;/p&gt;




&lt;h2&gt;
  
  
  Presentation delay and the React render cost
&lt;/h2&gt;

&lt;p&gt;Presentation delay is the third phase — from when handlers finish to when the screen actually updates. This is React's territory.&lt;/p&gt;

&lt;p&gt;A click handler that calls &lt;code&gt;setState&lt;/code&gt; and returns immediately still has to wait for React to reconcile and the browser to paint before the interaction is complete from INP's perspective. If reconciliation is expensive, presentation delay climbs.&lt;/p&gt;

&lt;p&gt;This is where &lt;code&gt;useTransition&lt;/code&gt; belongs — not as a general "make things faster" tool, but specifically to defer reconciliation work that doesn't need to block the visual acknowledgment of an interaction. The handler returns quickly, the browser paints a loading state or an immediate visual change, and then React reconciles the heavier update separately.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reading LoAF attribution in production
&lt;/h2&gt;

&lt;p&gt;The Long Animation Frames API — covered briefly in the previous article — becomes genuinely useful when you start reading its &lt;code&gt;scripts&lt;/code&gt; attribution in a production context.&lt;/p&gt;

&lt;p&gt;Each LoAF entry includes a list of scripts that contributed to the slow frame, with source locations and durations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&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;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;script&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;sendMetric&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;frameDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;scriptSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;scriptFunction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceFunctionName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;scriptDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;invokerType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invokerType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 'event-listener', 'user-callback', etc.&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long-animation-frame&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;invokerType&lt;/code&gt; tells you whether the script was triggered by an event listener, a setTimeout, a Promise callback, or something else. Filtering by &lt;code&gt;invokerType: 'event-listener'&lt;/code&gt; on a specific page shows you exactly which handlers are contributing to slow frames during interactions — with function names and source URLs.&lt;/p&gt;

&lt;p&gt;This replaces a lot of the guesswork involved in reproducing INP issues locally. Slow interactions often don't reproduce in DevTools because the lab environment doesn't have the same background tasks, cache state, and concurrent timers as production.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to look first
&lt;/h2&gt;

&lt;p&gt;Check &lt;code&gt;inputDelay&lt;/code&gt; in the event timing breakdown before touching handler code. If input delay is the dominant phase, look for long tasks running during the page's usage cycle — not just during load.&lt;/p&gt;

&lt;p&gt;If presentation delay is high on a specific interaction, that component's reconciliation cost is the target. Start with React DevTools Profiler on that specific action before generalizing.&lt;/p&gt;

&lt;p&gt;Processing time being the bottleneck is the most straightforward case — and also the least common in practice.&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>FID Is Dead. What INP Means for Your React App.</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Tue, 05 May 2026 14:02:00 +0000</pubDate>
      <link>https://dev.to/nosyos/fid-is-dead-what-inp-means-for-your-react-app-4ka6</link>
      <guid>https://dev.to/nosyos/fid-is-dead-what-inp-means-for-your-react-app-4ka6</guid>
      <description>&lt;p&gt;In March 2024, Google replaced First Input Delay with Interaction to Next Paint as an official Core Web Vital. FID is gone. INP is what matters now — and most React apps that were passing before are failing under the new standard without anyone realizing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What was wrong with FID
&lt;/h2&gt;

&lt;p&gt;FID measured how long the browser took to respond to the very first user interaction on a page. Click a button, FID measures the delay before the browser started processing that click. Just the first one. Just the start of processing, not the time until anything actually happened on screen.&lt;/p&gt;

&lt;p&gt;In practice, FID was easy to pass and bad at catching real responsiveness problems. A page could have an excellent FID score while every subsequent click — after the page was fully loaded and the user was actually using it — took 600ms to respond. FID had nothing to say about that.&lt;/p&gt;




&lt;h2&gt;
  
  
  What INP actually measures
&lt;/h2&gt;

&lt;p&gt;INP measures the full interaction latency for all interactions throughout the entire page session, not just the first. It captures the delay from when a user interacts (click, tap, keyboard input) to when the browser finishes rendering the visual response.&lt;/p&gt;

&lt;p&gt;The threshold: Good is under 200ms. Needs Improvement is 200–500ms. Poor is over 500ms.&lt;/p&gt;

&lt;p&gt;The shift matters because it exposes a category of problems FID never touched. Long-running JavaScript that doesn't affect first-load responsiveness but blocks the main thread during normal usage. React state updates that trigger expensive re-renders mid-session. Event handlers that do too much synchronous work before returning control to the browser.&lt;/p&gt;

&lt;p&gt;A React app with heavy component trees, lots of context consumers, and synchronous state updates can have a perfectly acceptable LCP and a terrible INP. Under FID, nobody would have noticed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where React apps tend to fail INP
&lt;/h2&gt;

&lt;p&gt;The most common causes in React specifically:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronous state updates that cascade.&lt;/strong&gt; A click handler updates state, which triggers a re-render of a large subtree, which blocks the main thread while React reconciles. If that reconciliation takes 300ms, the user sees a 300ms delay before anything on screen changes. The React Compiler helps here by reducing unnecessary re-renders, but it doesn't reduce the cost of renders that need to happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unoptimized event handlers.&lt;/strong&gt; An &lt;code&gt;onClick&lt;/code&gt; that does validation, makes a synchronous API call via a cached store, updates multiple pieces of state, and then re-renders — all before returning — is an INP problem waiting to be found.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useTransition&lt;/code&gt; is the right tool for expensive updates that don't need to block the interaction response:&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleClick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// This part runs immediately — updates the UI to acknowledge the interaction&lt;/span&gt;
  &lt;span class="nf"&gt;setButtonState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// This part is deferred — React schedules it without blocking&lt;/span&gt;
  &lt;span class="nf"&gt;startTransition&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="nf"&gt;setFilteredResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;computeExpensiveFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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 interaction response — acknowledging that something happened — is immediate. The expensive computation happens without blocking the input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third-party scripts running during interactions.&lt;/strong&gt; An analytics script that fires on every click event and does synchronous work is adding to your INP whether you wrote it or not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Long Animation Frames API
&lt;/h2&gt;

&lt;p&gt;Alongside INP, browsers shipped the Long Animation Frames API (LoAF) as a more precise replacement for Long Tasks.&lt;/p&gt;

&lt;p&gt;Long Tasks measured any main thread task over 50ms. LoAF measures animation frames that take over 50ms to render, with richer attribution — it tells you which scripts, which event handlers, and which rendering work contributed to the slow frame.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// entry.scripts shows which scripts contributed to the slow frame&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Slow frame:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Scripts:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripts&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="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long-animation-frame&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;scripts&lt;/code&gt; array in each entry is the part that changes the debugging workflow. Instead of knowing "something ran too long on the main thread," you know exactly which function in which file was responsible. This is significantly faster to diagnose than working backward from a Long Tasks timeline in the Performance panel.&lt;/p&gt;




&lt;h2&gt;
  
  
  Speculation Rules: prefetching gets declarative
&lt;/h2&gt;

&lt;p&gt;The Speculation Rules API lets you declare prefetch and prerender rules directly in HTML, without JavaScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"speculationrules"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prerender&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;where&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;href_matches&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/product/*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eagerness&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;moderate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;prerender&lt;/code&gt; goes further than &lt;code&gt;prefetch&lt;/code&gt; — it fully renders the page in a hidden tab, so navigation is instant. &lt;code&gt;eagerness: "moderate"&lt;/code&gt; triggers prerendering when the user holds the pointer over a matching link for 200ms (or on pointerdown if that happens sooner), not immediately on page load.&lt;/p&gt;

&lt;p&gt;For Next.js apps, the router already handles prefetching via &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt;, but Speculation Rules gives you declarative control for non-Next.js apps or edge cases the router doesn't cover.&lt;/p&gt;




&lt;h2&gt;
  
  
  Measuring INP from real users
&lt;/h2&gt;

&lt;p&gt;INP can't be meaningfully measured with synthetic tests. Lighthouse doesn't measure it in a way that reflects real interaction patterns — it simulates a few interactions, not the full session. The only honest INP measurement is from actual users doing actual things.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entryType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;sendMetric&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INP_candidate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Element&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;durationThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This captures interaction events over 100ms — the candidates that might become your INP score. Logging the target element tells you which UI components are the source of slow interactions, which is where the optimization work starts.&lt;/p&gt;

&lt;p&gt;If you're already monitoring LCP in production, adding INP measurement to the same pipeline is straightforward. I built &lt;a href="https://rpalert.dev" rel="noopener noreferrer"&gt;RPAlert&lt;/a&gt; to handle this for React apps — it tracks Web Vitals including INP from real browsers and alerts via Slack or Discord when thresholds are crossed. Given that INP is now an official ranking signal and most React apps haven't tuned for it, catching regressions as you work toward improvement is worth having in place.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to prioritize now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Measure your INP first.&lt;/strong&gt; Add the &lt;code&gt;PerformanceObserver&lt;/code&gt; above to production and collect a week of data. Look at which interactions are the worst offenders and which pages they're on. The distribution will tell you where to focus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit your event handlers.&lt;/strong&gt; Find click and input handlers that do significant synchronous work and identify which ones can be wrapped in &lt;code&gt;useTransition&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Switch from Long Tasks to LoAF&lt;/strong&gt; in your monitoring if you're already collecting Long Task data. The attribution is better and the LoAF data supersedes Long Tasks for debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check your INP score in Chrome UX Report&lt;/strong&gt; (via PageSpeed Insights or Search Console) for a baseline. If you're in the "Needs Improvement" or "Poor" range, it's affecting your search ranking today.&lt;/p&gt;




&lt;p&gt;The FID-to-INP transition isn't just a metric rename. It's a change in what "responsive" means for a passing grade. A React app that passed Core Web Vitals before March 2024 might be failing now without any code having changed. Worth checking.&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Performance Improvements Don't Last. Here's Why</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Thu, 30 Apr 2026 14:02:00 +0000</pubDate>
      <link>https://dev.to/nosyos/performance-improvements-dont-last-heres-why-4oc</link>
      <guid>https://dev.to/nosyos/performance-improvements-dont-last-heres-why-4oc</guid>
      <description>&lt;p&gt;A team spends a sprint optimizing LCP. Numbers improve. Six months later the app is slower than before the work started. Nobody made a single decision to make it slower. It just accumulated.&lt;/p&gt;

&lt;p&gt;This is the normal trajectory without structural changes. Individual optimizations decay. Culture doesn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the gains disappear
&lt;/h2&gt;

&lt;p&gt;Performance degrades through ordinary work. A developer adds a new dependency. A designer hands off a 1.4MB hero image and nobody checks the size. Marketing adds a tag via the tag manager. A component gets a useEffect with a missing dependency that triggers extra renders. Each change is small, reviewed individually, and ships fine.&lt;/p&gt;

&lt;p&gt;The problem is that performance review doesn't happen at the same granularity as code review. Code gets scrutinized line by line. Performance gets checked periodically, if at all, by whoever remembers to run Lighthouse.&lt;/p&gt;

&lt;p&gt;The improvements you made six months ago are gone not because someone undid them, but because the process that created the degradation in the first place was never changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Performance budgets only work with enforcement
&lt;/h2&gt;

&lt;p&gt;A performance budget is a threshold: bundle size under 200KB, LCP under 2.5s, no new Long Tasks over 150ms. Most teams that try budgets define them and then don't enforce them, which means they don't exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A budget without automated enforcement is a suggestion.&lt;/strong&gt; The only budget that changes behavior is one that fails a CI check and blocks a merge.&lt;/p&gt;

&lt;p&gt;Bundlesize and Lighthouse CI both integrate into GitHub Actions and can fail a PR when thresholds are crossed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/performance.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lighthouse CI&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;treosh/lighthouse-ci-action@v10&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;https://staging.yourapp.com&lt;/span&gt;
    &lt;span class="na"&gt;budgetPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./budget.json&lt;/span&gt;
    &lt;span class="na"&gt;uploadArtifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;budget.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"timings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"metric"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"largest-contentful-paint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2500&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"resourceSizes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"script"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a PR exceeds the budget, the check fails. The developer sees it before merge, not after deployment. This is the only version of a performance budget that actually works.&lt;/p&gt;

&lt;p&gt;Start with thresholds loose enough that you're not blocking everything immediately. Tighten them incrementally as the baseline improves. The goal at the start is to prevent regression, not to hit an ideal number.&lt;/p&gt;




&lt;h2&gt;
  
  
  Code review needs a performance lens
&lt;/h2&gt;

&lt;p&gt;Most teams review code for correctness, readability, and security. Performance is rarely on the checklist, which means expensive patterns ship unnoticed.&lt;/p&gt;

&lt;p&gt;A few things worth adding to your review process:&lt;/p&gt;

&lt;p&gt;New dependencies should trigger a bundle size check. &lt;code&gt;bundlephobia.com&lt;/code&gt; takes 30 seconds and shows exactly what a package adds to your bundle before you commit to it. A 40KB dependency that only uses two functions is worth questioning.&lt;/p&gt;

&lt;p&gt;Components that render lists should be reviewed for scale. A list component that works with 50 items in staging might create Long Tasks at 500. If it's not using virtualization and the data could grow, flag it.&lt;/p&gt;

&lt;p&gt;Images added to the codebase should have explicit dimensions and a format check. If someone commits a PNG larger than 100KB for a UI element, that's worth a comment.&lt;/p&gt;

&lt;p&gt;None of this requires a formal checklist. It requires one or two engineers who know to look for it and normalize asking the question in review.&lt;/p&gt;




&lt;h2&gt;
  
  
  Make performance data visible to everyone
&lt;/h2&gt;

&lt;p&gt;Performance stays one engineer's problem when only one engineer can see the data. The moment your LCP numbers appear somewhere the whole team looks — a Slack channel, a dashboard on a shared screen, a weekly metric in the team standup — it becomes a shared concern.&lt;/p&gt;

&lt;p&gt;The practical version: route your performance alerts to a channel where the whole team is present. When a deploy causes an LCP regression and a Slack message appears in &lt;code&gt;#engineering&lt;/code&gt;, everyone sees it. The developer who shipped the change sees it. The product manager sees it. It becomes a team metric rather than an infrastructure metric.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://rpalert.dev" rel="noopener noreferrer"&gt;RPAlert&lt;/a&gt; partly for this reason — the Slack and Discord integration means performance regressions surface in the same place where the team already communicates. It's a small thing that changes who feels responsible for the numbers.&lt;/p&gt;

&lt;p&gt;The same logic applies to your analytics dashboard. If LCP trends are buried in a monitoring tool that only two people have logins for, performance will remain two people's concern.&lt;/p&gt;




&lt;h2&gt;
  
  
  Designers and PMs are part of this
&lt;/h2&gt;

&lt;p&gt;Performance problems that originate outside the engineering team can't be fixed purely by engineers. A design system that specifies large, uncompressed images as the standard will produce large, uncompressed images at every launch. A product process that doesn't include performance review before shipping a feature will produce features that haven't been evaluated for performance impact.&lt;/p&gt;

&lt;p&gt;The lowest-effort version of this: add performance to your definition of done. Before a feature ships, someone confirms the LCP element on affected pages hasn't gotten worse. Not a full audit — a single check. If it fails, it's a bug, same as a broken form submission.&lt;/p&gt;

&lt;p&gt;With designers specifically: the conversation is usually easier than expected once the data exists. Showing a designer that their 2MB hero image is causing a 1.2s LCP increase for mobile users is more persuasive than a general request to "optimize images." Specific numbers change specific behavior.&lt;/p&gt;




&lt;h2&gt;
  
  
  The learning investment pays off asymmetrically
&lt;/h2&gt;

&lt;p&gt;A single lunch-and-learn session where you walk the team through opening Chrome DevTools, running a Lighthouse audit, and interpreting the results changes how people work for months. Developers who've never looked at a performance waterfall start noticing things during their own testing. Designers start asking about image format before handing off assets.&lt;/p&gt;

&lt;p&gt;The return on two hours of internal education is disproportionate to the investment. It doesn't require bringing in an outside expert or building a curriculum. It requires one person who knows the tools well enough to show them to the rest of the team.&lt;/p&gt;




&lt;p&gt;Culture isn't a process you implement. It's the accumulated effect of small structural changes: enforcement that makes the budget real, review habits that catch expensive patterns early, visibility that makes performance everyone's number. The teams with consistently fast apps didn't get there through heroic optimization sprints. They just made it harder for the app to get slower without someone noticing.&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Slow Pages Cost Money. Here's How to Prove It.</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Tue, 28 Apr 2026 14:04:00 +0000</pubDate>
      <link>https://dev.to/nosyos/slow-pages-cost-money-heres-how-to-prove-it-4fbm</link>
      <guid>https://dev.to/nosyos/slow-pages-cost-money-heres-how-to-prove-it-4fbm</guid>
      <description>&lt;p&gt;Performance work stalls not because engineers don't care, but because the business case is vague. "The app feels faster" doesn't unlock budget. "We reduced LCP by 800ms and checkout conversion went up 12%" does.&lt;/p&gt;

&lt;p&gt;The teams that get sustained investment in performance are the ones who learned to speak in numbers that matter to stakeholders. Here's how to build that argument.&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers that already exist
&lt;/h2&gt;

&lt;p&gt;You don't need to run your own study. The data is well-established at this point:&lt;/p&gt;

&lt;p&gt;Google's research on Core Web Vitals found that sites meeting the "Good" threshold for LCP see 24% fewer abandoned page loads compared to sites in the "Poor" range. Deloitte found that a 0.1s improvement in mobile site speed correlates with an 8% increase in conversions for retail sites. Vodafone saw a 31% increase in sales after a 31% improvement in LCP.&lt;/p&gt;

&lt;p&gt;These aren't cherry-picked outliers. The pattern holds across industries: slower pages lose users at predictable rates, and the loss scales with how slow the experience is.&lt;/p&gt;

&lt;p&gt;The most direct way to frame it for a non-technical stakeholder: every 100ms of additional load time costs some percentage of your conversions. The exact number varies by industry, audience, and baseline speed, but the direction is never ambiguous.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to calculate your own cost
&lt;/h2&gt;

&lt;p&gt;Generic industry statistics are useful for initial buy-in. Your own data is what closes the argument.&lt;/p&gt;

&lt;p&gt;Start with what you have. Most teams have analytics that show page load time alongside conversion or retention metrics. Segment your users by LCP performance bucket — "Good" under 2.5s, "Needs Improvement" 2.5–4s, "Poor" over 4s — and compare conversion rates across those groups.&lt;/p&gt;

&lt;p&gt;If your checkout conversion rate for users with LCP under 2.5s is 4.2% and for users with LCP over 4s it's 2.8%, the math becomes concrete. If 20% of your traffic is in the "Poor" bucket and you process 10,000 checkouts per month, closing that gap is worth roughly 280 additional conversions per month at whatever your average order value is.&lt;/p&gt;

&lt;p&gt;This isn't a controlled experiment — there are confounding variables, slower devices correlate with other demographic factors, and so on. But it's directionally correct and it's your data, which is far more persuasive to a leadership team than a Deloitte study about retail sites.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cost of a regression is easier to calculate than the cost of being slow
&lt;/h2&gt;

&lt;p&gt;Here's the argument that often lands faster: quantify what a performance regression costs, then show what it costs to not catch one quickly.&lt;/p&gt;

&lt;p&gt;A deploy that increases LCP by 1.5s across your checkout flow and sits undetected for 4 hours: take your hourly transaction volume, apply the conversion rate delta you measured above, and multiply. For a moderately busy e-commerce site, a 4-hour regression can mean tens of thousands of dollars in lost revenue. That number is exact, not an estimate, because you have the actual transaction data from that window.&lt;/p&gt;

&lt;p&gt;The ROI argument for monitoring then writes itself. If catching a regression in 10 minutes instead of 4 hours saves $30,000, the cost of whatever tooling enables that is trivially justified.&lt;/p&gt;




&lt;h2&gt;
  
  
  Measuring before and after is non-negotiable
&lt;/h2&gt;

&lt;p&gt;The most common failure mode in performance projects: teams do the work, don't have the data to prove it made a difference, and can't justify the next round of investment.&lt;/p&gt;

&lt;p&gt;You need real-user measurements before you start any optimization, during the work, and continuously afterward. Not Lighthouse scores — those measure synthetic conditions on a controlled machine. Field data from actual users, segmented by page and device type.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PerformanceObserver&lt;/code&gt; gives you this without a third-party dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;lcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;startTime&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;lcp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;sendMetric&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LCP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lcp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;deviceMemory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;deviceMemory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;largest-contentful-paint&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sending &lt;code&gt;deviceMemory&lt;/code&gt; alongside the metric lets you segment by device class — low-memory devices are a good proxy for slower hardware. The performance gap between your p50 and p75 users is often where the business impact lives.&lt;/p&gt;

&lt;p&gt;Once you have this instrumented, connect it to your analytics. LCP by page, by device, over time. When you ship an optimization, you'll see the distribution shift in the data. That shift is your ROI evidence.&lt;/p&gt;

&lt;p&gt;For the alerting side — catching regressions before they become hours-long revenue events — I built &lt;a href="https://rpalert.dev" rel="noopener noreferrer"&gt;RPAlert&lt;/a&gt; to handle the threshold monitoring and Slack/Discord notification layer. The "cost of a 4-hour regression" calculation I described above is exactly the argument for having that kind of alerting in place: the monitoring cost is fixed and small, the regression cost is variable and potentially large.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to frame this for stakeholders
&lt;/h2&gt;

&lt;p&gt;Engineers tend to present performance work as a technical improvement. Stakeholders hear "we made some things faster." The same work framed as "we identified that 18% of our users are experiencing load times that reduce checkout conversion by 1.4 percentage points, and we have a plan to move them into the Good tier" lands differently.&lt;/p&gt;

&lt;p&gt;A few framings that work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revenue at risk:&lt;/strong&gt; "X% of sessions have LCP over 4s. Based on our conversion data, this segment converts at Y% vs Z% for fast sessions. At our current volume, that's approximately $N/month in lost revenue."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regression cost:&lt;/strong&gt; "Our last deploy regression ran for 4 hours before we caught it. Based on transaction volume during that window, the estimated revenue impact was $N. We're proposing monitoring that would have caught it in under 10 minutes."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Competitive framing:&lt;/strong&gt; Run WebPageTest on your main competitors. If you're 1.2s slower on mobile than your closest competitor, that's a meaningful talking point in a room where people think about market share.&lt;/p&gt;




&lt;h2&gt;
  
  
  KPIs worth tracking continuously
&lt;/h2&gt;

&lt;p&gt;LCP p75 by page — the 75th percentile is what Google uses for Core Web Vitals thresholds, and it's the right target because it represents your slower users, not the median.&lt;/p&gt;

&lt;p&gt;Regression frequency and MTTR (mean time to resolution) — how often you have regressions and how quickly you fix them. This makes the monitoring ROI argument over time.&lt;/p&gt;

&lt;p&gt;Conversion rate by performance bucket — LCP Good vs. Needs Improvement vs. Poor, segmented from your analytics. This is the number that connects engineering work to business outcomes.&lt;/p&gt;

&lt;p&gt;None of these require expensive tooling to start. They require making the measurement a consistent practice, which is the harder organizational problem.&lt;/p&gt;




&lt;p&gt;The teams that make performance a sustained priority aren't the ones with the most engineering time or the biggest budgets. They're the ones who connected their performance metrics to the numbers the business already cares about. That connection starts with measuring the right things from the right place — real users, in production, continuously.&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Testing on Fast Wi-Fi Is Not a Performance Test</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Thu, 23 Apr 2026 14:06:00 +0000</pubDate>
      <link>https://dev.to/nosyos/testing-on-fast-wi-fi-is-not-a-performance-test-5gol</link>
      <guid>https://dev.to/nosyos/testing-on-fast-wi-fi-is-not-a-performance-test-5gol</guid>
      <description>&lt;h1&gt;
  
  
  Testing on Fast Wi-Fi Is Not a Performance Test
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Tags: #react #performance #webdev #javascript&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Most performance testing happens on a MacBook Pro, over a fast home or office connection, with a warm browser cache. Then you deploy and wonder why the numbers are different in production.&lt;/p&gt;

&lt;p&gt;The gap isn't mysterious. You were never testing what your users experience.&lt;/p&gt;




&lt;h2&gt;
  
  
  What your local setup hides from you
&lt;/h2&gt;

&lt;p&gt;Three things consistently make local testing optimistic:&lt;/p&gt;

&lt;p&gt;Your CPU is fast. A React component tree that reconciles in 30ms on your development machine can take 150ms on a mid-range Android phone from two years ago. JavaScript execution time scales with CPU speed, not network speed. Throttling your network doesn't help here.&lt;/p&gt;

&lt;p&gt;Your cache is warm. You've loaded the page dozens of times during development. The browser has cached your fonts, your CSS, your JS bundles. A first-time visitor has none of that. Cold cache loads can be 2–3x slower than what you see after the third reload.&lt;/p&gt;

&lt;p&gt;Your connection is fast and stable. Office and home wifi is typically 50–200Mbps with low latency. A user on 4G in a building with poor signal might be getting 5Mbps with 200ms latency. The same 300KB JavaScript bundle takes 0.4s on your connection and 3.2s on theirs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Chrome DevTools throttling: useful, not sufficient
&lt;/h2&gt;

&lt;p&gt;DevTools lets you simulate slower network conditions and CPU performance from the Network and Performance panels. This is genuinely useful for catching obvious regressions. It's not a substitute for real-device testing.&lt;/p&gt;

&lt;p&gt;For network throttling, the built-in presets are a reasonable starting point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Fast 3G": ~1.5Mbps download, 40ms latency — approximates a decent mobile connection&lt;/li&gt;
&lt;li&gt;"Slow 3G": ~400Kbps download, 200ms latency — approximates a poor signal or congested network&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To add CPU throttling alongside network throttling, open the Performance panel and set the CPU throttle multiplier before recording. 4x slowdown is a reasonable approximation of a mid-range phone; 6x for lower-end devices.&lt;/p&gt;

&lt;p&gt;The limitation: CPU throttling in DevTools is a multiplier applied to your existing hardware. A 6x slowdown on a fast Mac still doesn't fully reproduce the memory pressure, thermal constraints, or GPU pipeline behavior of a real low-end device. It's a direction, not a destination.&lt;/p&gt;




&lt;h2&gt;
  
  
  WebPageTest gives you closer to the real thing
&lt;/h2&gt;

&lt;p&gt;WebPageTest runs tests on actual devices and actual network connections, not simulations on your hardware. The free tier at webpagetest.org lets you test from real locations against real mobile device profiles.&lt;/p&gt;

&lt;p&gt;A few settings that matter:&lt;/p&gt;

&lt;p&gt;Set the test location to somewhere geographically relevant to your users. Latency scales with distance. Testing from a US East Coast location when half your users are in Southeast Asia will give you unrealistically fast numbers.&lt;/p&gt;

&lt;p&gt;Use a mobile device profile. The "Motorola G (gen 4)" or similar mid-range Android preset is a reasonable proxy for the median visitor to most consumer apps.&lt;/p&gt;

&lt;p&gt;Enable "First View" only initially — it's the cold cache scenario, which is what new users experience and what you most need to optimize for.&lt;/p&gt;

&lt;p&gt;The waterfall view is where the value is. Look at what loads, in what order, what blocks what. Third-party scripts that seem fast locally often appear as long blocking requests here. Fonts that you never notice on your warm-cache machine show up as early blocking resources. It's the closest thing to watching a real user load your page.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lighthouse: right tool, often wrong setup
&lt;/h2&gt;

&lt;p&gt;Lighthouse is easy to run, well-documented, and measures the right things. It's also commonly run in a way that undermines its usefulness.&lt;/p&gt;

&lt;p&gt;Running Lighthouse on localhost — against your dev server, with hot module reloading active — gives you numbers that have nothing to do with production. Run it against your production or staging URL.&lt;/p&gt;

&lt;p&gt;Running it on a fast connection without throttling gives you numbers your slowest users will never see. The default Lighthouse settings in Chrome DevTools apply simulated throttling automatically; if you're running it via the CLI, check your throttling configuration.&lt;/p&gt;

&lt;p&gt;Running it once and treating the number as stable is also a mistake. Lighthouse results vary by 10–15% between runs on the same page due to background processes and timing variations. Run it three times and take the median.&lt;/p&gt;




&lt;h2&gt;
  
  
  The ceiling on simulation
&lt;/h2&gt;

&lt;p&gt;Every simulation tool has the same ceiling: it's running on your infrastructure, with your hardware, and making assumptions about user conditions that may not match reality.&lt;/p&gt;

&lt;p&gt;The only way to know what your actual users experience is to measure it from their browsers. &lt;code&gt;PerformanceObserver&lt;/code&gt; gives you real field data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;lcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;startTime&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;lcp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;sendMetric&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LCP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lcp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;largest-contentful-paint&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distribution of real-user LCP is almost always wider than what your local tests suggest. The p75 is what matters for Core Web Vitals — the 75th percentile user's experience, not the median. That user might be on a slow connection in a weak signal area, and your simulation never represented them.&lt;/p&gt;

&lt;p&gt;Once you have real-user data, you also get deploy-time regression detection. If a change you shipped moves the p75 LCP from 1.9s to 3.1s, you want to know within minutes. I built &lt;a href="https://rpalert.dev" rel="noopener noreferrer"&gt;RPAlert&lt;/a&gt; specifically for this — it monitors LCP and Long Tasks from real browsers and sends a Slack or Discord alert when thresholds are crossed. The simulation tools tell you what might happen; real-user monitoring tells you what did.&lt;/p&gt;




&lt;p&gt;Use DevTools throttling to catch things before they ship. Use WebPageTest to get a more honest picture of production conditions. Use real-user measurement to know what's actually happening. They're not substitutes for each other — they answer different questions at different points in the development cycle.&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Where to Start with React Performance</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Wed, 22 Apr 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/nosyos/where-to-start-with-react-performance-1g83</link>
      <guid>https://dev.to/nosyos/where-to-start-with-react-performance-1g83</guid>
      <description>&lt;p&gt;You've probably already tried something. Added &lt;code&gt;useMemo&lt;/code&gt; in a few places. Run Lighthouse. Checked the bundle size. Maybe split a route or two.&lt;/p&gt;

&lt;p&gt;And the app still feels slow.&lt;/p&gt;

&lt;p&gt;The issue is usually not the optimization — it's that the mental model came later, or never. Performance work done without a clear picture of what you're measuring is mostly guesswork. Some of it sticks. A lot doesn't.&lt;/p&gt;

&lt;p&gt;The articles below are ordered so that each one gives you something concrete before you move to the next. They're not meant to be read in a weekend. Work through one, apply it to your actual app, then come back.&lt;/p&gt;




&lt;h2&gt;
  
  
  Understand what the browser measures before you touch anything
&lt;/h2&gt;

&lt;p&gt;Before profiling, before optimizing, read &lt;a href="https://dev.to/nosyos/core-web-vitals-explained-what-they-are-how-to-measure-them-and-why-they-matter-for-react-apps-3f2p"&gt;Core Web Vitals Explained&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;LCP, INP, CLS — these aren't SEO checkboxes. They're the closest thing we have to a standardized measurement of how fast your app feels to a real user. The article walks through what each metric actually captures, how to read them in a React app, and which thresholds matter in practice. If you've skimmed the MDN page and moved on, this will fill in the gaps that MDN skips.&lt;/p&gt;




&lt;h2&gt;
  
  
  Find your LCP element before you do anything else
&lt;/h2&gt;

&lt;p&gt;Lighthouse will show green image audits while your LCP sits at 4.2 seconds. I've seen it. The culprit was a CSS &lt;code&gt;background-image&lt;/code&gt; used for the hero. &lt;code&gt;next/image&lt;/code&gt; doesn't touch those. Nobody had checked which element was actually being measured.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/nosyos/most-lcp-fixes-come-down-to-one-image-2i09"&gt;Most LCP Fixes Come Down to One Image&lt;/a&gt; is about exactly this: the diagnosis step most developers skip. You cannot fix LCP reliably until you know what element the browser is treating as "largest." That's the only point of this article, and it's worth the ten minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two sources of drag that don't show up in your component tree
&lt;/h2&gt;

&lt;p&gt;You fix the hero. LCP improves. The page still feels sluggish during interaction. This is usually long tasks — JavaScript work that blocks the main thread long enough for users to notice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/nosyos/long-tasks-are-quietly-killing-your-react-apps-performance-3487"&gt;Long Tasks Are Quietly Killing Your React App's Performance&lt;/a&gt; explains what they are, where to find them in DevTools, and why React apps are particularly prone to generating them. Read this before you reach for any scheduler-level fixes.&lt;/p&gt;

&lt;p&gt;Then read &lt;a href="https://dev.to/nosyos/the-scripts-you-didnt-write-are-slowing-down-your-app"&gt;The Scripts You Didn't Write Are Slowing Down Your App&lt;/a&gt;. Analytics tags, chat widgets, tag managers firing pixels for campaigns that ended months ago — these all compete for main thread time. The engineering team usually has no idea how many are running or what they cost. This article gives you the tools to find out.&lt;/p&gt;




&lt;h2&gt;
  
  
  Your development environment is not your production environment
&lt;/h2&gt;

&lt;p&gt;This one is short. Read &lt;a href="https://dev.to/nosyos/why-your-app-feels-fast-in-staging-and-slow-in-production-27e6"&gt;Why Your App Feels Fast in Staging and Slow in Production&lt;/a&gt;, then look at how you've been profiling. CPU throttling, real network conditions, cold cache behavior — the article is a checklist you can run against your current setup immediately.&lt;/p&gt;




&lt;h2&gt;
  
  
  Don't assume the React Compiler handles everything
&lt;/h2&gt;

&lt;p&gt;If you're on React 19 or thinking about the compiler, &lt;a href="https://dev.to/nosyos/memoization-in-the-react-compiler-era-what-actually-changes-3e6b"&gt;What the React Compiler Quietly Skips&lt;/a&gt; covers what it does and doesn't automate. Side effects, context, components with non-deterministic output — these are still your problem. The article is specific enough to be useful without being alarmist about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stop letting fixed problems come back
&lt;/h2&gt;

&lt;p&gt;Performance regressions are quiet. A dependency updates, a feature ships, and the LCP you worked to fix climbs back above three seconds. Nobody notices until a user says something.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/nosyos/detecting-performance-regressions-right-after-you-deploy-403f"&gt;Catching React Performance Regressions Before Your Users Do&lt;/a&gt; is about wiring performance checks into CI so regressions surface before merge. It's the step most teams skip because it feels like overhead — until they've been burned once.&lt;/p&gt;

&lt;p&gt;CI catches regressions in test conditions. But production is different. &lt;a href="https://dev.to/nosyos/monitoring-past-performance-vs-alerting-real-time-issues-what-react-teams-are-missing-hdc"&gt;Monitoring Past Performance vs. Alerting Real-Time Issues&lt;/a&gt; draws the line between historical analytics and real-time alerting. Most teams have one and assume it covers both. It doesn't.&lt;/p&gt;

&lt;p&gt;If you'd rather not build the alerting layer yourself, &lt;a href="https://rpalert.dev" rel="noopener noreferrer"&gt;RPAlert&lt;/a&gt; detects LCP degradation in production and sends a notification to Slack or Discord within 60 seconds. There's a free tier, and setup is one &lt;code&gt;npm install&lt;/code&gt; and a component wrapper. It's not a replacement for understanding your metrics — but once you understand them, you'll want to know the moment they break.&lt;/p&gt;




&lt;h2&gt;
  
  
  Read in this order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/nosyos/core-web-vitals-explained-what-they-are-how-to-measure-them-and-why-they-matter-for-react-apps-3f2p"&gt;Core Web Vitals Explained&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/nosyos/most-lcp-fixes-come-down-to-one-image-2i09"&gt;Most LCP Fixes Come Down to One Image&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/nosyos/long-tasks-are-quietly-killing-your-react-apps-performance-3487"&gt;Long Tasks Are Quietly Killing Your React App's Performance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/nosyos/the-scripts-you-didnt-write-are-slowing-down-your-app"&gt;The Scripts You Didn't Write Are Slowing Down Your App&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/nosyos/why-your-app-feels-fast-in-staging-and-slow-in-production-27e6"&gt;Why Your App Feels Fast in Staging and Slow in Production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/nosyos/memoization-in-the-react-compiler-era-what-actually-changes-3e6b"&gt;What the React Compiler Quietly Skips&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/nosyos/detecting-performance-regressions-right-after-you-deploy-403f"&gt;Catching React Performance Regressions Before Your Users Do&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/nosyos/monitoring-past-performance-vs-alerting-real-time-issues-what-react-teams-are-missing-hdc"&gt;Monitoring Past Performance vs. Alerting Real-Time Issues&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Eight articles. Try each concept against your own app before moving to the next. Doing it that way, this sequence will teach you more about production React performance than most tutorials manage in twice the length.&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The Scripts You Didn't Write Are Slowing Down Your App</title>
      <dc:creator>nosyos</dc:creator>
      <pubDate>Tue, 21 Apr 2026 14:24:00 +0000</pubDate>
      <link>https://dev.to/nosyos/the-scripts-you-didnt-write-are-slowing-down-your-app-4lnp</link>
      <guid>https://dev.to/nosyos/the-scripts-you-didnt-write-are-slowing-down-your-app-4lnp</guid>
      <description>&lt;p&gt;I once audited a page where nearly 40% of the main thread blocking time came from a tag manager firing scripts that the engineering team didn't know were still active. Analytics from a vendor they'd switched away from. A heatmap tool from a trial nobody cancelled. A pixel for an ad campaign that ended months ago.&lt;/p&gt;

&lt;p&gt;Nobody wrote those scripts. They accumulated.&lt;/p&gt;




&lt;h2&gt;
  
  
  What third-party scripts actually cost you
&lt;/h2&gt;

&lt;p&gt;The performance impact happens in two places: network and main thread.&lt;/p&gt;

&lt;p&gt;On the network side, each script is an additional HTTP request, often to a slow external domain with no SLA on response time. A single chat widget might make 4–6 requests before it's ready. On a slow connection, this shows up in your waterfall as a long chain of blocking or near-blocking resources.&lt;/p&gt;

&lt;p&gt;On the main thread, third-party scripts run JavaScript. That JavaScript competes with your React app for CPU time. A script that takes 80ms to parse and execute on a fast development machine might take 350ms on a mid-range Android phone. Every millisecond it holds the main thread is a millisecond your app can't respond to user input or complete a render.&lt;/p&gt;

&lt;p&gt;The combination of late network requests and CPU-heavy execution is why third-party scripts are so good at pushing LCP. The browser is waiting on resources it didn't know it needed, while the main thread is occupied with someone else's code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Find out what's actually running
&lt;/h2&gt;

&lt;p&gt;Before you optimize anything, run your production URL through WebPageTest with a mobile throttling preset and look at the waterfall. Sort by domain. You'll see every request, grouped by origin.&lt;/p&gt;

&lt;p&gt;The question to ask for each third-party domain: does the engineering team know this is here, and what breaks if it doesn't load?&lt;/p&gt;

&lt;p&gt;Chrome's Coverage tab gives you the JavaScript utilization angle — how much of each loaded script is actually executed on the page. A script that's 90% unused is paying full network and parse cost for very little value.&lt;/p&gt;

&lt;p&gt;The surprises are usually in the tag manager. If your site uses Google Tag Manager or a similar tool, open it and look at what's configured. Marketing and analytics teams often have direct access and add tags without engineering review. The list is rarely what anyone expects.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stop loading scripts at the worst possible time
&lt;/h2&gt;

&lt;p&gt;Most third-party scripts don't need to be ready before your app is interactive. Analytics doesn't need to fire before the user can click anything. Chat widgets don't need to be loaded before the hero image is painted.&lt;/p&gt;

&lt;p&gt;The default behavior — scripts in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; without &lt;code&gt;async&lt;/code&gt; or &lt;code&gt;defer&lt;/code&gt; — blocks HTML parsing entirely. This is almost never what you want for third-party code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;async&lt;/code&gt; loads the script in parallel with parsing, but executes it as soon as it downloads, which can still interrupt parsing at a bad moment. &lt;code&gt;defer&lt;/code&gt; loads in parallel and waits until parsing is complete before executing. For most analytics and tracking scripts, &lt;code&gt;defer&lt;/code&gt; is the right default.&lt;/p&gt;

&lt;p&gt;For scripts that are truly non-critical — chat widgets, feedback tools, anything that doesn't affect the initial render — load them after hydration:&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="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;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://third-party-widget.com/widget.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;document&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="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;script&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;This pushes execution entirely past React's initial render and hydration cycle. The widget loads when it loads. Your LCP doesn't wait for it.&lt;/p&gt;

&lt;p&gt;In Next.js, the &lt;code&gt;Script&lt;/code&gt; component handles this with the &lt;code&gt;strategy&lt;/code&gt; prop:&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Script&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// afterInteractive: loads after hydration, good for analytics&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Script&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://analytics.example.com/script.js"&lt;/span&gt; &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"afterInteractive"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// lazyOnload: loads during browser idle time, good for chat widgets&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Script&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://widget.example.com/chat.js"&lt;/span&gt; &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"lazyOnload"&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;&lt;code&gt;beforeInteractive&lt;/code&gt; exists for scripts that genuinely need to be ready before the page is usable. For third-party code, that's almost never true.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tag managers are the hard part
&lt;/h2&gt;

&lt;p&gt;A tag manager with unrestricted access is effectively a way for non-engineers to inject arbitrary JavaScript into production. The scripts themselves might be fine individually. The problem is the total: 8 tags that each take 50ms to execute is 400ms of main thread time that engineering had no visibility into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit the tag manager on a regular schedule.&lt;/strong&gt; Not annually — quarterly at minimum. For each tag: who owns it, what it does, and what happens if it's removed. Treat it like a dependency review. Tags accumulate the same way npm packages do, and they're harder to spot because they're not in the codebase.&lt;/p&gt;

&lt;p&gt;Two practical rules that help: require engineering sign-off before any new tag is added, and set a network budget threshold that triggers a review if total third-party bytes cross it. Neither is bureaucratic overhead — they're the minimum to prevent the page you ship from drifting away from the page you tested.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem doesn't stay solved
&lt;/h2&gt;

&lt;p&gt;You optimize the loading strategy, audit the tag manager, remove the stale scripts. A month later, marketing adds a new analytics tool. Another month, a new A/B testing SDK. Each addition seems small in isolation.&lt;/p&gt;

&lt;p&gt;Measuring this from real users catches it before it accumulates. Adding the &lt;code&gt;PerformanceObserver&lt;/code&gt; for Long Tasks gives you a signal when a new script is hitting the main thread harder than expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;sendMetric&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LongTask&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;longtask&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want that signal to reach you automatically when a new script causes a regression — without manually checking dashboards — I built &lt;a href="https://rpalert.dev" rel="noopener noreferrer"&gt;RPAlert&lt;/a&gt; to handle this. It monitors LCP and Long Tasks from real browsers and sends a Slack or Discord alert when thresholds are crossed. It's caught more than a few cases where a new marketing tag quietly pushed LCP past the threshold right after it was deployed.&lt;/p&gt;




&lt;p&gt;The engineering team usually gets blamed when the app is slow. The scripts that actually caused it were added by someone else, through a tool that didn't require a code review. Getting visibility into that layer is half the work.&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
