Measuring LCP in React Apps: Step-by-Step
Measuring Largest Contentful Paint (LCP) in a React app is the first step to optimization. You capture the exact moment the largest visible element finishes rendering using the web-vitals library or the PerformanceObserver API, then log that timing to your analytics backend. This reveals which page routes have slow LCP, which network conditions trigger regressions, and which React components or third-party resources are the bottleneck.
What Is LCP and When Does It Fire?
LCP is the render time of the largest image, text block, video, or canvas element visible in the viewport when the user first loads the page. In React, this is usually a hero image, main headline, or large content card. LCP fires once (the first time a Largest Contentful element renders) and does not update later. If the user scrolls and sees a larger element below the fold, LCP does not change—it is locked to the moment of the initial largest element.
LCP timing includes the time to fetch the resource (image download) plus the time to render it on the DOM. A 2-second LCP means the user had to wait 2 seconds to see the most important content. Google's "good" threshold is 2.5 seconds or less; pages above 4 seconds trigger a "poor" ranking signal.
Using the web-vitals Library
The web-vitals npm package is the easiest way to measure LCP in React. It wraps the PerformanceObserver API and handles browser inconsistencies. Install it with npm install web-vitals, then import the getLCP function:
// src/index.js or src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { getLCP, getCLS, getINP } from 'web-vitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
// Measure LCP
getLCP(metric => {
console.log('LCP:', metric.value, 'ms');
// Send to analytics
fetch('/api/analytics/lcp', {
method: 'POST',
body: JSON.stringify({
metric: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
url: window.location.href,
timestamp: new Date().toISOString(),
}),
});
});
The metric object contains:
name: "LCP"value: LCP time in millisecondsrating: "good", "needs-improvement", or "poor"delta: change since the last report (relevant for repeat page views)id: unique identifier for deduplication in analytics
The getLCP callback fires after the page has fully loaded and LCP has been determined, typically 2–5 seconds after page load. It will not fire for every interaction; it fires once per page load.
Logging LCP to Analytics
Create a wrapper function to send LCP data to your analytics provider or a custom backend. Many companies send Core Web Vitals to Google Analytics, Datadog, or Sentry. Here is a production example:
// src/utils/analytics.js
export function reportWebVitals() {
import('web-vitals').then(({ getLCP, getINP, getCLS, getFCP }) => {
getLCP(metric => {
// Log to your backend
sendMetricToBackend(metric);
// Also send to Google Analytics (if you use it)
if (window.gtag) {
window.gtag('event', metric.name, {
value: Math.round(metric.value),
event_category: 'web-vitals',
event_label: metric.id,
non_interaction: true,
});
}
});
getINP(metric => {
sendMetricToBackend(metric);
});
getCLS(metric => {
sendMetricToBackend(metric);
});
});
}
async function sendMetricToBackend(metric) {
try {
const payload = {
metric: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
};
await fetch('/api/analytics/web-vitals', {
method: 'POST',
body: JSON.stringify(payload),
keepalive: true, // ensures request completes even if user leaves
});
} catch (err) {
console.error('Failed to send web-vitals:', err);
}
}
// In your index.js or App.tsx, call early:
reportWebVitals();
The keepalive: true flag ensures the fetch completes even if the user closes the tab or navigates away mid-request.
Debugging LCP with PerformanceObserver
For deeper debugging, use the PerformanceObserver API directly to see which element was marked as "Largest Contentful Paint":
// src/utils/debugLcp.js
export function debugLCP() {
const observer = new PerformanceObserver(list => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP candidate:', {
element: lastEntry.element,
renderTime: lastEntry.renderTime,
loadTime: lastEntry.loadTime,
size: lastEntry.size,
startTime: lastEntry.startTime,
duration: lastEntry.duration,
url: lastEntry.url, // for images
});
});
// Observe largest-contentful-paint entries
observer.observe({ entryTypes: ['largest-contentful-paint'] });
// Stop observing after 5 seconds (LCP is usually determined early)
setTimeout(() => observer.disconnect(), 5000);
}
// Call in your App component's useEffect
import { useEffect } from 'react';
import { debugLCP } from './utils/debugLcp';
export default function App() {
useEffect(() => {
debugLCP();
}, []);
return (/* ... */);
}
The lastEntry.element property shows you the exact DOM node marked as LCP—helpful for identifying which component or image is the slowest.
Checking LCP in Lighthouse
Lighthouse (Chrome DevTools) shows LCP in its performance audit. Open Chrome DevTools, click the Lighthouse tab, and run an audit. The "Largest Contentful Paint" row shows the LCP timing and an estimate of which element caused it. Lighthouse also suggests optimization opportunities like deferring offscreen images, minifying CSS, or enabling text compression.
Lighthouse is a lab test, so its results may differ from field data. A page might score well in Lighthouse on your desktop but fail in field data because real users on slower networks or older phones experience much longer LCP.
Identifying Slow LCP in React Components
In React, LCP is often slow due to:
- Large images: A hero image that takes 3 seconds to download will have LCP around 3+ seconds.
- Render-blocking CSS or JS: Stylesheets or scripts loaded synchronously delay rendering.
- Long server response time: If your API is slow, the HTML reaches the browser late.
- Unoptimized third-party scripts: Analytics, ads, or chat widgets can block the main thread.
Use Chrome DevTools' Network tab to see which resources take the longest. Look for resources that:
- Are marked as "high priority" (images, fonts)
- Have a long "download time" (> 1 second)
- Block the main thread (red bars in the timeline)
Key Takeaways
- LCP measures the render time of the largest visible element on page load.
- Use the
web-vitalslibrary'sgetLCP()function to capture LCP in production React apps. - Send LCP data to your analytics backend for monitoring and alerting.
- The metric object includes
value(milliseconds),rating(good/needs-improvement/poor), andidfor deduplication. - Use PerformanceObserver to debug which element caused LCP and why.
- Lighthouse provides lab LCP scores, but field data is more reliable for real-world performance.
Frequently Asked Questions
How long after page load does LCP fire?
LCP fires when the largest element finishes rendering, typically 0.5–5 seconds after page load. The getLCP callback may fire several seconds later if the browser is still painting. Always call getLCP early in your app (in index.js or main.tsx) to ensure it is captured.
Can LCP change after the page fully loads?
No. LCP is locked to the moment the initial largest element renders. If the user scrolls and discovers a larger element below the fold, LCP does not update. However, if new content is injected into the viewport within the first few seconds, that can shift which element is "largest."
What if my LCP element is a React component that fetches data?
LCP includes network time for image downloads but does not include the time for API calls (unless an image URL is the LCP candidate). If your LCP element is text inside a component that awaits API data, the API latency contributes to LCP. Use code-splitting and lazy loading to defer data-dependent components below the fold.
Is getLCP the same as getLCP(..., true) (reportAllChanges)?
By default, getLCP() fires once with the final LCP value. The reportAllChanges flag allows repeated callbacks as the page changes. For production, use the default (once only) to avoid overhead. For debugging, set reportAllChanges: true to see all LCP candidates.
Further Reading
- web-vitals GitHub Repository — Official library code, examples, and contribution guidelines.
- Web.dev: Optimize Largest Contentful Paint — Deep dive into LCP causes and optimization techniques.
- Chrome DevTools Performance Tab — How to use DevTools to measure LCP locally.
- PerformanceObserver API — MDN reference for low-level performance metrics.