DEV Community

AMAL
AMAL

Posted on

Web Vitals in JavaScript: How to Measure, Debug, and Fix Real-World Performance Problems

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,
});
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

consider:

<img
  src="/hero.webp"
  width="1200"
  height="600"
  alt="Product dashboard"
/>
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

break expensive work into smaller chunks.

Depending on the workload, techniques include:

  • scheduler.yield()
  • setTimeout
  • requestIdleCallback
  • Web Workers
  • Chunked processing

For example:

async function processItems(items) {
  for (const item of items) {
    process(item);

    await scheduler.yield();
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

3. Avoid unnecessary rendering

In React applications, expensive rendering can become an interaction problem.

Imagine:

<Search />

<VeryLargeList />

<ComplexDashboard />

<Charts />

<Analytics />
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

Better:

<img
  src="/product.jpg"
  width="800"
  height="600"
  alt="Product"
/>
Enter fullscreen mode Exit fullscreen mode

Or use CSS:

.product-image {
  aspect-ratio: 4 / 3;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then:

import {
  onLCP,
  onINP,
  onCLS,
} from 'web-vitals';

onLCP(console.log);
onINP(console.log);
onCLS(console.log);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

you can use:

import {
  onLCP,
  onINP,
  onCLS,
} from 'web-vitals/attribution';
Enter fullscreen mode Exit fullscreen mode

This provides additional diagnostic information that can help identify the cause of poor metric values.

For example:

onLCP((metric) => {
  console.log(metric);
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Identify the LCP element
  2. Determine when it was requested
  3. Determine when it finished loading
  4. Determine when it was rendered
  5. 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
}
Enter fullscreen mode Exit fullscreen mode

Include useful dimensions:

{
  "metric": "LCP",
  "value": 2800,
  "route": "/products",
  "device": "mobile",
  "browser": "Chrome",
  "connection": "4g",
  "release": "1.4.2"
}
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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

If you found this useful, I'd love to hear how you currently measure and debug performance in your applications.

Top comments (0)