DEV Community

Shivaraj Sapali
Shivaraj Sapali

Posted on

Your React App Is Secretly Failing Google's Tests

Your React app might be passing CI, looking great in DevTools, and scoring well on your MacBook — but still secretly failing Google's ranking tests on real user devices.

I've audited dozens of React and Next.js apps. Almost every single one was failing at least one Core Web Vital. The builds passed. The CI was green. But Google was quietly penalizing them in search rankings every day.

This article changes that. We'll go metric by metric, look at exactly what breaks each one in React, and write real code to fix it.


What Google Actually Measures

Core Web Vitals are three user-centric performance metrics Google uses as ranking signals:

Metric Full Name Good Poor
LCP Largest Contentful Paint < 2.5s > 4.0s
INP Interaction to Next Paint < 200ms > 500ms
CLS Cumulative Layout Shift < 0.1 > 0.25

Note: FID (First Input Delay) was retired in March 2024. INP replaced it — and it's harder to pass. FID measured the delay before your handler ran. INP measures the entire interaction: delay + handler execution + rendering.


01 — LCP: The Hero Image Problem

LCP measures when the largest element on screen finishes painting. In most React apps, this is a hero image or large heading. The single most common cause of a bad LCP?

Lazy-loading the hero image.

React devs apply loading="lazy" to every image by default. That's great for below-the-fold images. It's catastrophic for your hero — you just told the browser: "please load this last."

Fix 1: Prioritize Your Hero in Next.js

// ❌ WRONG — kills LCP
<Image
  src="/hero.jpg"
  alt="Hero"
  loading="lazy"  // ← catastrophic for LCP
  width={1200}
  height={600}
/>

// ✅ CORRECT — tells browser to load this first
<Image
  src="/hero.jpg"
  alt="Hero"
  priority                // preloads in <head>
  fetchPriority="high"   // resource hint to browser
  sizes="100vw"
  width={1200}
  height={600}
/>
Enter fullscreen mode Exit fullscreen mode

Fix 2: Preload Critical Resources

// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        {/* Preload your LCP image — before React even hydrates */}
        <link
          rel="preload"
          href="/hero.jpg"
          as="image"
          fetchPriority="high"
        />
      </head>
      <body>{children}</body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

Fix 3: Eliminate Render-Blocking Fonts

// app/layout.tsx
import { Inter } from 'next/font/google'

// Fonts downloaded at build time, served locally,
// never block rendering. Zero layout shift.
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',   // text visible during font load
  preload: true,
})
Enter fullscreen mode Exit fullscreen mode

02 — INP: Where React's Reconciler Hurts You

INP replaced FID in 2024 and is a much stricter judge. Where FID only measured delay before your handler started, INP measures the entire interaction — from user tap/click to the next frame painting on screen.

"A slow React re-render is now a ranking signal. Think about that."

The most common INP killers in React: expensive useState updates that trigger deep tree re-renders, synchronous data transformations inside event handlers, and unoptimized long lists.

Fix 1: Defer Non-Critical State Updates with useTransition

// components/SearchBox.tsx
import { useState, useTransition, useDeferredValue } from 'react'

function SearchBox() {
  const [query, setQuery] = useState('')
  const [isPending, startTransition] = useTransition()

  // The input update is urgent — happens immediately
  // The results re-render is deferred — can be interrupted
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value
    startTransition(() => {
      setQuery(value)  // won't block typing
    })
  }

  // useDeferredValue as an alternative — for prop-based scenarios
  const deferredQuery = useDeferredValue(query)

  return (
    <>
      <input onChange={handleChange} placeholder="Search..." />
      {isPending && <Spinner />}
      <Results query={deferredQuery} />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Fix 2: Virtualize Long Lists

Rendering 1,000 DOM nodes kills INP. Use @tanstack/react-virtual to only render what's visible:

// components/ProductList.tsx
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'

function ProductList({ products }: { products: Product[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: products.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72,  // row height in px
    overscan: 5,
  })

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map(item => (
          <ProductRow
            key={item.key}
            product={products[item.index]}
            style={{ transform: `translateY(${item.start}px)` }}
          />
        ))}
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Fix 3: Memoize Expensive Computations

// hooks/useFilteredProducts.ts
import { useMemo } from 'react'

function useFilteredProducts(products: Product[], filters: Filters) {
  return useMemo(() => {
    // This runs ONLY when products or filters change
    // Not on every parent re-render
    return products
      .filter(p => p.price >= filters.minPrice)
      .filter(p => p.category === filters.category)
      .sort((a, b) => a.price - b.price)
  }, [products, filters.minPrice, filters.category])
}
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Use React DevTools Profiler to find what's actually re-rendering. The "Highlight updates when components render" option is your best friend for INP debugging.


03 — CLS: The Layout Shift That Loses Sales

CLS quantifies visual instability. You know when you're about to tap a button and an ad loads and your tap hits the wrong thing? That's CLS. It's infuriating — and Google knows it.

Top causes in React apps: images without dimensions, async-loaded components that don't reserve space, fonts swapping in after content paints.

Fix 1: Always Set Image Dimensions

// ❌ Browser doesn't know height until image loads
// → content below jumps when image appears
<img src="/product.jpg" alt="product" />

// ✅ Browser reserves space before image loads — zero CLS
<Image
  src="/product.jpg"
  alt="Product name"
  width={400}
  height={400}
  style={{ aspectRatio: '1 / 1' }}
/>

// ✅ For dynamic images: use aspect-ratio CSS
const imageStyle = {
  width: '100%',
  aspectRatio: '16 / 9',
  objectFit: 'cover' as const
}
Enter fullscreen mode Exit fullscreen mode

Fix 2: Skeleton Screens for Async Content

// components/UserCard.tsx
function UserCard({ userId }: { userId: string }) {
  const { data, isLoading } = useUser(userId)

  if (isLoading) {
    return (
      // Same dimensions as real card — zero CLS
      <div style={{ height: 120, width: '100%' }}>
        <Skeleton height={120} />
      </div>
    )
  }

  return (
    <div style={{ height: 120 }}>
      <Avatar src={data.avatar} />
      <h3>{data.name}</h3>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Fix 3: Anchor Fonts with size-adjust (Next.js handles this for you)

// app/layout.tsx
import { Inter } from 'next/font/google'

// Next.js automatically generates a @font-face fallback
// with size-adjust, ascent-override, descent-override
// so the fallback font takes EXACTLY the same space as Inter.
// Zero layout shift when custom font loads.
const inter = Inter({
  subsets: ['latin'],
  adjustFontFallback: true,  // generates the override
  display: 'swap',
})
Enter fullscreen mode Exit fullscreen mode

04 — Measuring in Production (Not Just DevTools)

Lighthouse runs in a controlled lab environment. Your users are not in a lab — they're on a 4G connection, on a $200 Android phone, with 40 tabs open. You need field data.

  • Lab data = Lighthouse. Fast, great for CI regressions. But lies about real UX.
  • Field data = Chrome User Experience Report (CrUX). What real users experience. What Google uses for rankings.

Instrument Your App with web-vitals

// lib/vitals.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals'

function sendToAnalytics(metric: Metric) {
  navigator.sendBeacon('/api/vitals', JSON.stringify({
    name: metric.name,
    value: Math.round(
      metric.name === 'CLS' ? metric.value * 1000 : metric.value
    ),
    rating: metric.rating,   // 'good' | 'needs-improvement' | 'poor'
    id: metric.id,
    url: window.location.href,
  }))
}

// Measure all Core Web Vitals
onCLS(sendToAnalytics)   // Cumulative Layout Shift
onINP(sendToAnalytics)   // Interaction to Next Paint
onLCP(sendToAnalytics)   // Largest Contentful Paint
onFCP(sendToAnalytics)   // First Contentful Paint
onTTFB(sendToAnalytics)  // Time to First Byte
Enter fullscreen mode Exit fullscreen mode

In Next.js App Router

// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Analytics } from '@vercel/analytics/react'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <SpeedInsights />  {/* Real user CWV data */}
        <Analytics />      {/* Page views */}
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

05 — Quick Reference Cheatsheet

Metric Good React Killer Fix
LCP < 2.5s Lazy hero image, render-blocking CSS priority on Image, preload link
INP < 200ms Heavy re-renders, unvirtualized lists useTransition, virtualization
CLS < 0.1 No image dimensions, async content Skeleton screens, explicit dimensions
TTFB < 800ms No caching, no CDN, SSR blocking ISR, CDN, cache headers
FCP < 1.8s Large JS bundles, slow server Code splitting, streaming SSR

The 10-Point Pre-Deploy Audit Checklist

  • [ ] Hero image has priority prop — no loading="lazy"
  • [ ] All images have explicit width and height attributes
  • [ ] Fonts loaded via next/font with display: swap
  • [ ] Async components have skeleton placeholders with matching heights
  • [ ] Lists with 100+ items use virtualization (@tanstack/react-virtual)
  • [ ] Heavy state updates wrapped in useTransition
  • [ ] Expensive computations wrapped in useMemo
  • [ ] No third-party scripts blocking main thread without async/defer
  • [ ] web-vitals instrumented and reporting to analytics
  • [ ] Lighthouse CI running in GitHub Actions

Don't forget: Check your actual production URL at pagespeed.web.dev — not localhost. The gap between what you think your score is and what Google actually measures can be humbling.


The Bottom Line

Core Web Vitals aren't just a ranking factor. They're a proxy for whether your users are having a good time.

  • Bad LCP = users staring at a blank screen
  • Bad INP = the app feels broken when they interact with it
  • Bad CLS = the page jumps around while they try to use it

The good news: every single one is fixable. Next.js has built-in solutions for most of them. React 18+ concurrent features were designed specifically to help with INP.

Start with your LCP. Fix your hero image. Run PageSpeed Insights on your production URL. Then work down the checklist.

Your users — and Google — will notice.


If this helped you, share it with your team. Drop questions in the comments — happy to help debug your specific setup.

Top comments (0)