
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>
That's it. There's nothing there. The browser has to:
- Download the HTML
- Download the JavaScript bundle
- Parse and execute the JS
- Let React render the UI
- 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>
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
With Fiber:
Render a bit → pause → let browser paint → continue → smooth UI
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
});
}
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
Then in your index.js:
import { getCLS, getLCP, getINP } from 'web-vitals';
getCLS(console.log);
getLCP(console.log);
getINP(console.log);
You'll see something like this in your console:
{ name: 'LCP', value: 2100 }
{ name: 'CLS', value: 0.03 }
{ name: 'INP', value: 145 }
For production monitoring, instead of console.log, send the data to your analytics:
getLCP((metric) => {
fetch('/analytics', {
method: 'POST',
body: JSON.stringify(metric),
});
});
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'));
Users only download what they need.
2. Preload your hero image
<link rel="preload" as="image" href="/hero.webp" />
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>
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" />
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>
);
}
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]);
2. Use useTransition for non-urgent updates
const [isPending, startTransition] = useTransition();
startTransition(() => {
setFilteredList(bigList.filter(fn));
});
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>
4. Avoid unnecessary re-renders
const MyComponent = React.memo(ExpensiveComponent);
const handleClick = useCallback(() => {
doSomething();
}, []);
Production Debug Workflow
When Web Vitals are bad in production, here's how to actually debug it:
- Run Google Lighthouse (DevTools → Lighthouse tab)
- Check which metric is failing
- If LCP is bad → look at bundle size and images
- If INP is bad → look for heavy JS on the main thread
- 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
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';
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)