DEV Community

Munna Thakur
Munna Thakur

Posted on

Why Most React Apps Fail Core Web Vitals (And How to Actually Fix Them)


You built a React app. It looks great. But Google PageSpeed gives you a 45 score and your users are bouncing.

Sound familiar?

The problem is usually Web Vitals. And most React developers either don't know about them or don't know how to fix them in production.

Let's fix that.


What Are Web Vitals (Quick Version)

Google measures 3 things about your website:

Metric What It Means Good Score
LCP How fast does your main content show up? under 2.5s
CLS Does your page jump around while loading? under 0.1
INP How fast does your UI respond when user clicks something? under 200ms

These 3 things directly affect your SEO ranking and how users feel on your site.


The Big Problem With React CSR Apps

Here's something a lot of developers miss.

When you open a regular React app (Create React App or Vite), the initial HTML looks like this:

<div id="root"></div>
<script src="main.js"></script>
Enter fullscreen mode Exit fullscreen mode

That's it. There's nothing there. The browser has to:

  1. Download the HTML
  2. Download the JavaScript bundle
  3. Parse and execute the JS
  4. Let React render the UI
  5. Only then does the user see something

So your LCP is always going to be slow. Because the user is staring at a blank screen while all of this is happening.

Compare that to Next.js (SSR):

<div id="root">
  <h1>Welcome to My Site</h1>
  <img src="hero.webp" />
</div>
Enter fullscreen mode Exit fullscreen mode

Server already sent the full HTML. Browser just paints it immediately. LCP is done.

That's why Next.js apps almost always have better LCP out of the box.


How React Fiber Actually Helps (The Internal Stuff)

React Fiber is React's rendering engine (introduced in React 16). Most people have heard of it but don't know what it actually does.

Before Fiber, rendering was fully synchronous. React would start rendering your component tree and could not stop until it was done. If your tree was big, the browser would freeze.

Fiber changed this. It breaks rendering into small chunks and can pause and resume work between browser frames.

Think of it like this:

Before Fiber:

Start rendering → can't stop → browser freezes → bad INP
Enter fullscreen mode Exit fullscreen mode

With Fiber:

Render a bit → pause → let browser paint → continue → smooth UI
Enter fullscreen mode Exit fullscreen mode

This is why modern React APIs like useTransition and useDeferredValue are possible. They tell Fiber to treat certain updates as low priority.

const [isPending, startTransition] = useTransition();

function handleSearch(value) {
  startTransition(() => {
    setResults(filterBigList(value)); // low priority
  });
}
Enter fullscreen mode Exit fullscreen mode

User typing stays fast. The heavy filtering happens in the background. INP stays good.


How to Check Web Vitals in Your React App

First, install the package:

npm install web-vitals
Enter fullscreen mode Exit fullscreen mode

Then in your index.js:

import { getCLS, getLCP, getINP } from 'web-vitals';

getCLS(console.log);
getLCP(console.log);
getINP(console.log);
Enter fullscreen mode Exit fullscreen mode

You'll see something like this in your console:

{ name: 'LCP', value: 2100 }
{ name: 'CLS', value: 0.03 }
{ name: 'INP', value: 145 }
Enter fullscreen mode Exit fullscreen mode

For production monitoring, instead of console.log, send the data to your analytics:

getLCP((metric) => {
  fetch('/analytics', {
    method: 'POST',
    body: JSON.stringify(metric),
  });
});
Enter fullscreen mode Exit fullscreen mode

How to Fix Each Metric

Fixing LCP (Slow Loading)

1. Split your code

Don't ship everything in one big bundle.

const Dashboard = React.lazy(() => import('./Dashboard'));
Enter fullscreen mode Exit fullscreen mode

Users only download what they need.

2. Preload your hero image

<link rel="preload" as="image" href="/hero.webp" />
Enter fullscreen mode Exit fullscreen mode

This tells the browser to start downloading the image before it even finds the <img> tag.

3. Use WebP images

WebP is a modern image format by Google. Same quality, way smaller file size.

Format File Size
JPEG 500 KB
PNG 800 KB
WebP ~150 KB

That's up to 70% smaller. Smaller image = faster download = better LCP.

<picture>
  <source srcSet="hero.webp" type="image/webp" />
  <img src="hero.jpg" alt="hero" width="800" height="400" />
</picture>
Enter fullscreen mode Exit fullscreen mode

4. Move to SSR

If LCP is consistently bad, the real fix is server-side rendering. Use Next.js or Remix. They pre-render HTML on the server so users never stare at a blank screen.


Fixing CLS (Layout Shifts)

This one is simple but gets ignored a lot.

Always give your images a size:

// Bad - browser doesn't know the size, layout shifts when image loads
<img src="/banner.webp" />

// Good
<img src="/banner.webp" width="800" height="400" />
Enter fullscreen mode Exit fullscreen mode

Reserve space for dynamic content:

If you're loading data and showing it, use a skeleton placeholder so the layout doesn't jump.

function UserCard() {
  return (
    <div style={{ height: '200px' }}>
      {data ? <Profile /> : <Skeleton />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Fixing INP (Slow Interactions)

INP gets bad when you run heavy JavaScript on the main thread.

1. Use useMemo for expensive calculations

// Bad - runs on every render
function App() {
  const result = heavyCalculation(data);
  return <div>{result}</div>;
}

// Good - only runs when data changes
const result = useMemo(() => heavyCalculation(data), [data]);
Enter fullscreen mode Exit fullscreen mode

2. Use useTransition for non-urgent updates

const [isPending, startTransition] = useTransition();

startTransition(() => {
  setFilteredList(bigList.filter(fn));
});
Enter fullscreen mode Exit fullscreen mode

3. Virtualize long lists

Rendering 10,000 items kills INP. Use react-window to only render what's visible.

import { FixedSizeList as List } from 'react-window';

<List
  height={400}
  itemCount={10000}
  itemSize={35}
  width={300}
>
  {Row}
</List>
Enter fullscreen mode Exit fullscreen mode

4. Avoid unnecessary re-renders

const MyComponent = React.memo(ExpensiveComponent);

const handleClick = useCallback(() => {
  doSomething();
}, []);
Enter fullscreen mode Exit fullscreen mode

Production Debug Workflow

When Web Vitals are bad in production, here's how to actually debug it:

  1. Run Google Lighthouse (DevTools → Lighthouse tab)
  2. Check which metric is failing
  3. If LCP is bad → look at bundle size and images
  4. If INP is bad → look for heavy JS on the main thread
  5. If CLS is bad → look for images without dimensions

To analyze your bundle size:

npm run build
npx source-map-explorer build/static/js/*.js
Enter fullscreen mode Exit fullscreen mode

This shows you exactly which libraries are eating your bundle. Common culprits:

// Bad - imports the entire lodash library
import _ from 'lodash';

// Good - imports only what you need
import debounce from 'lodash/debounce';
Enter fullscreen mode Exit fullscreen mode

Quick Summary

Problem Fix
Large JS bundle Code splitting with React.lazy
Slow hero image WebP format + preload link
Layout shifts Always define image dimensions
Heavy interactions useTransition + useMemo
Long lists react-window virtualization
Consistently bad LCP Move to Next.js SSR

One Last Thing

Most React performance issues come down to one thing: too much work happening on the main thread at the wrong time.

Fiber handles a lot of this automatically, but you still need to help it. Use the concurrent features (useTransition, useDeferredValue), split your code, optimize your images, and measure everything with the web-vitals package.

Don't guess. Measure first, then fix.


If this helped, drop a reaction. And if you've run into a Web Vitals issue I didn't cover, mention it in the comments — happy to dig into it.

Top comments (0)