A website can work perfectly and still feel slow.
The API responds.
The page loads.
There are no JavaScript errors.
But users might still experience:
- Content taking too long to appear
- Buttons feeling unresponsive
- Layout jumping while the page loads
- Images appearing late
- UI freezing during interactions
- Content moving unexpectedly
This is where Web Vitals become useful.
Web Vitals give us measurable signals for understanding how users actually experience a website.
The three Core Web Vitals are:
- LCP — Largest Contentful Paint
- INP — Interaction to Next Paint
- CLS — Cumulative Layout Shift
In this article, we'll look at what these metrics mean, how JavaScript affects them, how to measure them, how to debug problems, and most importantly, how to fix them.
What are Web Vitals?
Web Vitals are a set of metrics designed to measure important aspects of the user experience on the web.
The three Core Web Vitals focus on:
| Metric | What it measures |
|---|---|
| LCP | Loading performance |
| INP | Responsiveness |
| CLS | Visual stability |
There are other useful metrics such as TTFB and FCP, but LCP, INP, and CLS are the three Core Web Vitals.
The important thing to understand is that these aren't simply JavaScript metrics.
They measure the user's experience, and JavaScript is one of many factors that can influence that experience.
For example:
Slow server
↓
Slow HTML response
↓
Resources start loading late
↓
Content renders late
↓
Poor LCP
Or:
Large JavaScript task
↓
Main thread blocked
↓
User clicks a button
↓
Browser can't respond immediately
↓
Poor INP
Or:
Image has no dimensions
↓
Page renders
↓
Image loads
↓
Content moves
↓
Poor CLS
Understanding these chains is much more useful than simply looking at a score.
1. LCP — Largest Contentful Paint
LCP measures how quickly the largest content element visible in the viewport is rendered.
This might be:
- A hero image
- A large heading
- A product image
- A banner
- A large block of text
In simple terms:
How long does it take before the main content of the page becomes visible?
LCP thresholds
| LCP | Rating |
|---|---|
| ≤ 2.5s | Good |
| 2.5s – 4.0s | Needs improvement |
| > 4.0s | Poor |
The goal is to keep LCP at 2.5 seconds or less for most users.
What causes a bad LCP?
A common assumption is:
"My LCP is bad because my image is too large."
That can be true, but LCP can be affected by several different stages.
Think of LCP as:
TTFB
+
Resource Load Delay
+
Resource Load Time
+
Render Delay
LCP
For example:
Server response 1.2s
Resource load delay 0.6s
Image download 1.5s
Render delay 0.7s
LCP 4.0s
Optimizing only the image wouldn't necessarily solve the entire problem.
Finding the LCP element
Chrome DevTools is one of the best places to start.
Open:
DevTools
→ Performance
→ Record
→ Reload the page
Look for the LCP marker in the Performance timeline.
You can also inspect LCP programmatically.
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.startTime);
console.log('Element:', lastEntry.element);
}).observe({
type: 'largest-contentful-paint',
buffered: true,
});
This helps answer:
Which element is actually responsible for my LCP?
For production applications, the web-vitals library is usually a better option because it handles the metric reporting details for you.
Common LCP problems
1. Slow server response
If your server takes too long to return HTML, everything downstream is delayed.
For example:
Request
↓
Server processing
↓
Database query
↓
API calls
↓
HTML response
If this takes 1.5 seconds, you've already consumed a significant portion of your LCP budget.
What can you do?
Look at TTFB — Time to First Byte.
Potential solutions include:
- Server-side caching
- CDN caching
- Database optimization
- Reducing backend work
- Static generation
- Streaming HTML
- Edge rendering
- Reducing unnecessary API calls
2. Large images
Images are common LCP elements.
Instead of:
<img src="/hero.png">
consider:
<img
src="/hero.webp"
width="1200"
height="600"
alt="Product dashboard"
/>
Use an appropriate image format and size.
Don't send a 4000px image to a device displaying it at 600px.
3. Lazy-loading the LCP image
This is a common mistake.
If your hero image is the LCP element, don't automatically add:
<img
src="/hero.webp"
loading="lazy"
alt="Hero"
/>
Lazy loading can delay a resource that is actually critical to the initial viewport.
Lazy loading is generally more appropriate for images that are below the fold.
4. JavaScript delaying rendering
Consider:
const data = await fetch('/api/products');
renderProducts(data);
If the page cannot render meaningful content until JavaScript finishes downloading, parsing, executing, and fetching data, LCP can suffer.
Ask yourself:
Does this JavaScript actually need to execute before the main content can appear?
If not, move it out of the critical path.
2. INP — Interaction to Next Paint
INP is about responsiveness.
Imagine a user clicks:
Add to cart
The user expects the interface to respond quickly.
If the browser is busy doing expensive JavaScript work, the interaction can feel frozen.
INP measures interaction latency throughout the page lifecycle and focuses on how quickly the page can visually respond after user interactions.
INP thresholds
| INP | Rating |
|---|---|
| ≤ 200ms | Good |
| 200ms – 500ms | Needs improvement |
| > 500ms | Poor |
Why JavaScript has a huge impact on INP
JavaScript runs primarily on the browser's main thread.
The main thread is responsible for many important browser tasks:
- JavaScript
- DOM
- Style calculation
- Layout
- Paint
- User input
If you execute a long JavaScript task:
function processHugeDataset(data) {
for (let i = 0; i < 10_000_000; i++) {
// expensive work
}
}
the browser may not be able to respond to user input until that work finishes.
This creates a long task.
A real-world example
Imagine a search page.
The user types:
laptop
And your application does:
input.addEventListener('input', (event) => {
const value = event.target.value;
const results = hugeDataset.filter((item) => {
return item.name
.toLowerCase()
.includes(value.toLowerCase());
});
render(results);
});
If hugeDataset contains hundreds of thousands of records, every keystroke could trigger expensive work.
The experience becomes:
Keypress
↓
JavaScript runs
↓
Browser is busy
↓
Rendering is delayed
↓
Poor INP
How to improve INP
1. Break up long tasks
Instead of doing everything synchronously:
processEverything();
break expensive work into smaller chunks.
Depending on the workload, techniques include:
scheduler.yield()setTimeoutrequestIdleCallback- Web Workers
- Chunked processing
For example:
async function processItems(items) {
for (const item of items) {
process(item);
await scheduler.yield();
}
}
The goal is to give the browser opportunities to process other work between chunks.
2. Move expensive work to a Web Worker
For CPU-heavy operations, Web Workers can keep expensive computation away from the main UI thread.
Main Thread
│
│ message
▼
Web Worker
│
│ expensive calculation
▼
Main Thread
Example:
const worker = new Worker('/worker.js');
worker.postMessage(data);
worker.onmessage = (event) => {
render(event.data);
};
3. Avoid unnecessary rendering
In React applications, expensive rendering can become an interaction problem.
Imagine:
<Search />
<VeryLargeList />
<ComplexDashboard />
<Charts />
<Analytics />
If changing the search input causes the entire application tree to render again, interactions can become expensive.
Look for:
- Unnecessary re-renders
- Expensive calculations
- Large component trees
- Huge lists
- Expensive effects
- Synchronous state updates
Virtualization can also help when rendering large lists.
3. CLS — Cumulative Layout Shift
CLS measures visual stability.
You've probably experienced this:
You try to click:
Buy Now
But an advertisement loads above it.
Suddenly:
Buy Now
moves down.
You click.
You accidentally click something else.
That's a layout shift.
CLS thresholds
| CLS | Rating |
|---|---|
| ≤ 0.1 | Good |
| 0.1 – 0.25 | Needs improvement |
| > 0.25 | Poor |
Lower is better.
What causes CLS?
Common causes include:
- Images without dimensions
- Ads without reserved space
- Dynamically injected content
- Web fonts causing layout changes
- Components appearing after JavaScript executes
- Content inserted above existing content
The classic image problem
Bad:
<img src="/product.jpg" alt="Product">
Better:
<img
src="/product.jpg"
width="800"
height="600"
alt="Product"
/>
Or use CSS:
.product-image {
aspect-ratio: 4 / 3;
}
The browser can now reserve the required space before the image loads.
Dynamic content can cause CLS
Consider:
const banner = document.createElement('div');
banner.innerHTML = `
<div class="promotion">
20% off today!
</div>
`;
document.body.prepend(banner);
If this happens after the page has rendered, everything below it can move.
Instead, reserve the space from the beginning.
.promotion-container {
min-height: 80px;
}
Fonts can cause layout shifts
A custom font may load after the initial render.
The browser initially renders:
Fallback font
Then switches to:
Custom font
If the font has different metrics, text dimensions can change and cause layout shifts.
Consider:
font-display: swap;
You can also use font metric overrides where appropriate.
Measuring Web Vitals with JavaScript
One of the easiest ways to collect Web Vitals is the web-vitals package.
Install it:
npm install web-vitals
Then:
import {
onLCP,
onINP,
onCLS,
} from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);
This is useful during development, but production monitoring is where things get much more interesting.
Sending Web Vitals to your backend
Instead of logging metrics to the console:
onLCP(console.log);
you can send them to your analytics endpoint:
import {
onLCP,
onINP,
onCLS,
} from 'web-vitals';
function sendToAnalytics(metric) {
fetch('/api/web-vitals', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
}),
headers: {
'Content-Type': 'application/json',
},
keepalive: true,
});
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Now your backend can collect real-user performance data.
Don't just collect the number
Knowing:
LCP = 4.2 seconds
isn't enough.
You need to know:
- Which page?
- Which element?
- Which device?
- Which browser?
- Which connection?
- Which release?
For example:
{
"metric": "LCP",
"value": 4200,
"route": "/products",
"device": "mobile",
"browser": "Chrome",
"connection": "4g"
}
Now you have something actionable.
Attribution: Finding the actual problem
The web-vitals package also provides an attribution build.
Instead of:
import {
onLCP,
onINP,
onCLS,
} from 'web-vitals';
you can use:
import {
onLCP,
onINP,
onCLS,
} from 'web-vitals/attribution';
This provides additional diagnostic information that can help identify the cause of poor metric values.
For example:
onLCP((metric) => {
console.log(metric);
});
Depending on the metric, attribution information can help answer questions such as:
- Which element was the LCP element?
- How long did the resource take?
- Was there render delay?
- Which DOM element was involved?
- Which interaction contributed to poor INP?
- Which elements shifted?
This is much more useful than simply knowing that a metric is "bad."
Debugging LCP in practice
Suppose production reports:
LCP = 4.8s
Don't immediately start optimizing random things.
Start with:
- Identify the LCP element
- Determine when it was requested
- Determine when it finished loading
- Determine when it was rendered
- Find the largest delay
For example:
TTFB 1.2s
Resource load delay 0.8s
Resource load time 1.9s
Render delay 0.9s
LCP 4.8s
Now the problem is much clearer.
You wouldn't solve this only by compressing the image.
You'd investigate all four stages.
Debugging INP in practice
Suppose:
INP = 650ms
The next question is:
Which interaction is causing it?
Maybe it's:
Click filter
↓
setState()
↓
Render 5,000 rows
↓
Sort data
↓
Calculate charts
↓
Browser finally paints
This is where Chrome DevTools becomes extremely useful.
Open:
DevTools
→ Performance
→ Start recording
→ Interact with the page
→ Stop recording
Look for:
- Long tasks
- Long event handlers
- Large rendering operations
- Forced synchronous layout
- Excessive JavaScript execution
Don't optimize blindly.
Find the expensive interaction first.
Debugging CLS in practice
If you see:
CLS = 0.31
you need to find the elements that moved.
Chrome DevTools' Performance panel can help identify layout shifts.
Look for:
Image loaded
↓
Layout shift
Ad inserted
↓
Layout shift
Font loaded
↓
Layout shift
React component mounted
↓
Layout shift
Then reserve the appropriate space or change the rendering strategy.
Lab data vs Real User Monitoring
This distinction is extremely important.
You might run Lighthouse and get:
LCP: 1.8s
INP: 80ms
CLS: 0.02
Then production users might experience:
LCP: 4.1s
INP: 320ms
CLS: 0.18
Both can be correct.
Why?
Your development machine might have:
- Fast CPU
- Fast network
- Large cache
- Desktop Chrome
- Low latency
Your users might have:
- Low-end Android
- Slow 4G
- Cold cache
- Older browser
- High latency
That's why Real User Monitoring (RUM) matters.
Lab tools are excellent for controlled debugging.
Real-user data tells you what is actually happening in production.
Don't optimize only for the average
Suppose you have:
Average LCP = 1.9s
That sounds great.
But perhaps:
p75 LCP = 2.7s
p95 LCP = 5.1s
Averages can hide users who are having a poor experience.
That's why performance analysis should consider percentiles, especially the 75th percentile.
Segment your Web Vitals
When collecting production data, don't store only:
{
"lcp": 2800
}
Include useful dimensions:
{
"metric": "LCP",
"value": 2800,
"route": "/products",
"device": "mobile",
"browser": "Chrome",
"connection": "4g",
"release": "1.4.2"
}
Now you can discover patterns.
For example:
Desktop LCP: 1.8s
Mobile LCP: 3.9s
Or:
Home page LCP: 1.7s
Product page LCP: 4.2s
Or:
Release 1.4.1: 2.0s
Release 1.4.2: 3.1s
That last one can reveal a performance regression introduced by a deployment.
A practical performance debugging workflow
Here's the workflow I recommend.
Step 1 — Measure
Start with:
- LCP
- INP
- CLS
Don't guess.
Step 2 — Identify the bad metric
For example:
LCP → 4.3s ❌
INP → 120ms ✅
CLS → 0.04 ✅
Now focus on LCP.
Step 3 — Find the responsible element
Ask:
What is the LCP element?
Maybe:
<img class="hero-image">
Now you have something concrete to investigate.
Step 4 — Break the metric into stages
For LCP:
TTFB
+
Resource Load Delay
+
Resource Load Time
+
Render Delay
Find the largest contributor.
Step 5 — Profile JavaScript
For INP problems:
Interaction
↓
Event handler
↓
JavaScript execution
↓
Rendering
↓
Paint
Use the Performance panel.
Look for long tasks.
Step 6 — Inspect layout shifts
For CLS:
- Which element moved?
- Why did it move?
- Could space have been reserved?
Step 7 — Fix the root cause
Don't just optimize whatever is easiest.
If the problem is:
Slow server
don't spend three days optimizing JavaScript.
If the problem is:
Huge JavaScript task
don't spend the day compressing images.
Fix the bottleneck.
Step 8 — Measure again
For example:
Before:
LCP = 4.3s
After:
LCP = 2.1s
Now you know the optimization actually worked.
Common fixes cheat sheet
| Problem | Possible solution |
|---|---|
| Slow LCP | Improve TTFB |
| Large LCP image | Compress/resize image |
| LCP image requested late | Prioritize/preload appropriately |
| Render blocked by JS | Reduce critical JS |
| Large JS bundle | Code splitting |
| Long JS tasks | Break work into smaller tasks |
| Heavy computation | Web Worker |
| Large React list | Virtualization |
| Excessive re-renders | Optimize component/state boundaries |
| Images causing CLS | Set dimensions/aspect ratio |
| Ads causing CLS | Reserve space |
| Dynamic content causing CLS | Reserve layout space |
| Font-related CLS | Optimize font loading/metrics |
| Third-party scripts | Delay/remove non-critical scripts |
JavaScript performance and Web Vitals are connected
One of the biggest misconceptions is:
"Web Vitals are just a frontend infrastructure problem."
They're not.
They're often an application architecture problem.
For example:
Large JavaScript bundle
↓
Long parse/compile time
↓
Delayed execution
↓
Delayed rendering
↓
LCP problem
And:
Large synchronous task
↓
Main thread blocked
↓
Interaction delayed
↓
INP problem
And:
Client-side rendering
↓
Content appears later
↓
Layout changes
↓
CLS problem
Performance should therefore be considered when designing the application, not only after the application becomes slow.
Performance is a system property
A fast website isn't created by one optimization.
It's usually the result of several layers working together:
┌──────────────┐
│ Backend │
└──────┬───────┘
↓
┌──────────────┐
│ Network │
└──────┬───────┘
↓
┌──────────────┐
│ HTML │
└──────┬───────┘
↓
┌──────────────┐
│ CSS │
└──────┬───────┘
↓
┌──────────────┐
│ JavaScript │
└──────┬───────┘
↓
┌──────────────┐
│ Render │
└──────┬───────┘
↓
┌──────────────┐
│ User Input │
└──────────────┘
Every layer can influence the user experience.
The tools I use for Web Vitals
A useful performance toolkit includes:
Chrome DevTools
Great for:
- Performance profiling
- Long tasks
- Layout shifts
- Network analysis
- CPU throttling
- Network throttling
Lighthouse
Useful for controlled lab audits.
PageSpeed Insights
Useful because it combines lab analysis with available real-user data.
Chrome UX Report
Useful for understanding real-world performance at scale.
web-vitals
Useful for collecting metrics directly from real users in your application.
A minimal production setup
You don't need a complicated observability platform to start collecting Web Vitals.
Start simple:
import {
onLCP,
onINP,
onCLS,
} from 'web-vitals';
function reportWebVital(metric) {
navigator.sendBeacon(
'/api/web-vitals',
JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
path: window.location.pathname,
})
);
}
onLCP(reportWebVital);
onINP(reportWebVital);
onCLS(reportWebVital);
Your backend can then store:
- metric
- value
- route
- device
- browser
- release
- timestamp
Once you have that data, you can build dashboards around it.
One final piece of advice
Don't treat Web Vitals as a score you need to make green.
Treat them as diagnostic signals.
If you see:
LCP = 4.5s
don't ask:
"How do I make LCP smaller?"
Ask:
"Why did the user have to wait 4.5 seconds before the main content appeared?"
If you see:
INP = 600ms
don't ask:
"How do I improve INP?"
Ask:
"What did the browser spend 600ms doing after the user's interaction?"
If you see:
CLS = 0.3
don't ask:
"How do I reduce CLS?"
Ask:
"What moved, and why didn't we reserve its space?"
That change in mindset makes performance optimization much more systematic.
Conclusion
Web Vitals give us a common language for talking about user experience.
The three Core Web Vitals answer three important questions:
LCP
↓
How quickly does the main content appear?
INP
↓
How quickly does the page respond to interaction?
CLS
↓
Does the page remain visually stable?
But measuring the numbers is only the first step.
The real engineering workflow is:
Measure
↓
Identify the bad metric
↓
Find the responsible element/interaction
↓
Trace the bottleneck
↓
Fix the root cause
↓
Measure again
↓
Monitor real users
That's how Web Vitals become more than a Lighthouse score.
They become a practical tool for building faster, more responsive, and more reliable web applications.
Further Reading
- Web Vitals — web.dev
- Core Web Vitals — web.dev
- Web Vitals JavaScript library
- Debug performance in the field — web.dev
- PageSpeed Insights
If you found this useful, I'd love to hear how you currently measure and debug performance in your applications.
Top comments (0)