This content originally appeared on DEV Community and was authored by Tianya School
Today, let’s dive into frontend performance metrics, focusing on FP (First Paint), FMP (First Meaningful Paint), LCP (Largest Contentful Paint), and CLS (Cumulative Layout Shift). These metrics are crucial for measuring webpage loading experiences, determining whether users perceive your site as “fast” or “stable.” We’ll break down how to measure, analyze, and apply these metrics step by step. Our goal is to deliver practical, no-nonsense technical details!
Why Care About Performance Metrics?
Frontend performance directly impacts user experience. Slow-loading pages or content that jumps around can drive users away. Google’s Core Web Vitals highlight LCP, CLS, and FID (First Input Delay, not covered in depth here) as key metrics, influencing search engine rankings. While FP and FMP aren’t official metrics, they’re vital for analyzing the rendering process. Let’s start with FP and unpack each metric.
FP (First Paint): When the Page Starts Showing Something
FP, or First Paint, marks the moment the browser draws the first pixel on the screen. This could be a background color, border, or any visual content—not necessarily the “main content” users care about. FP is a foundational metric, indicating how quickly the page transitions from blank to showing something.
How to Measure FP?
FP is the first rendering milestone, typically captured via the Performance API. It occurs when the browser starts painting pixels after parsing the DOM and CSSOM trees. Here’s how to measure it:
// Listen for page load completion
window.addEventListener('load', () => {
const performance = window.performance.getEntriesByType('paint');
const fp = performance.find(entry => entry.name === 'first-paint');
if (fp) {
console.log(`First Paint: ${fp.startTime.toFixed(2)}ms`);
} else {
console.log('FP not supported or not available');
}
});
This code uses performance.getEntriesByType('paint')
to fetch paint-related performance entries, finds the first-paint
entry, and logs its startTime
(in milliseconds). Most modern browsers (Chrome, Edge, Firefox) support this API.
What Does FP Represent?
FP marks the start of rendering, showing when the browser begins working. For example, a background color change or a loading spinner appearing triggers FP. It typically occurs when:
- The browser parses the HTML
<head>
and loads CSS. - It starts rendering the first DOM element (e.g.,
<body>
’s background).
Real-World Example
Consider a simple page:
<!DOCTYPE html>
<html>
<head>
<title>My Page</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<h1>Hello, World!</h1>
</body>
</html>
/* styles.css */
body {
background-color: #f0f0f0;
}
h1 {
color: navy;
}
When the browser loads styles.css
and applies the body
’s background color, the screen shifts from white to #f0f0f0
, triggering FP. The <h1>
text may not yet appear, as the CSSOM and DOM are still being built.
FP Limitations
FP only tracks the first pixel, not whether the content is meaningful. For instance, a background color change doesn’t help if the article or image users want isn’t loaded. This is where FMP and LCP come in.
FMP (First Meaningful Paint): When the Page Gets Meaningful
FMP, or First Meaningful Paint, captures when the page’s main content (e.g., article title, image, or navigation bar) first renders. It’s not a standard metric and can be vague, but tools like Lighthouse use it to analyze loading experiences.
How to Measure FMP?
FMP isn’t directly available via browser APIs, as “meaningful” content varies by page. Lighthouse estimates FMP by analyzing the rendering timeline, looking for when “visually significant” elements (e.g., large text or images) appear. Here’s how to measure it with Lighthouse in Node.js:
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
async function runLighthouse(url) {
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const options = { output: 'json', onlyCategories: ['performance'] };
const runnerResult = await lighthouse(url, options, chrome);
console.log('First Meaningful Paint:', runnerResult.lhr.audits['first-meaningful-paint'].numericValue, 'ms');
await chrome.kill();
}
runLighthouse('http://localhost:3000');
Install dependencies:
npm install lighthouse chrome-launcher
This code runs Lighthouse, analyzes the page, and returns the estimated FMP time (numericValue
in milliseconds). Lighthouse infers FMP by observing DOM changes and rendering events.
How FMP Works
FMP focuses on identifying the page’s most important content. The browser’s rendering process is:
- Parse HTML to build the DOM tree.
- Parse CSS to build the CSSOM tree.
- Combine DOM and CSSOM into a render tree.
- Compute layout to position elements.
- Paint pixels to the screen.
FMP typically occurs during the layout and paint stages, when key content (e.g., a hero banner or article title) appears in the viewport. Lighthouse prioritizes elements like the <main>
tag’s content.
Code Example
Consider a blog page:
<!DOCTYPE html>
<html>
<head>
<title>Blog Post</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header>
<h1>My Blog</h1>
</header>
<main>
<h2>Post Title</h2>
<p>This is the main content of the post...</p>
</main>
</body>
</html>
/* styles.css */
body {
background-color: #fff;
}
header {
background-color: navy;
color: white;
padding: 10px;
}
main {
margin: 20px;
}
FP might occur when the <header>
’s background color renders (parsed first). FMP is likely when the <main>
’s <h2>
and <p>
render, as they’re the user’s focus. Lighthouse prioritizes the <main>
tag’s rendering time.
FMP Challenges
FMP’s definition varies by page, and Lighthouse’s estimate may not always be accurate. For example:
- Single-page applications (SPAs) using React/Vue render content via JavaScript, delaying FMP until JS executes.
- Image-heavy pages tie FMP to image loading, which Lighthouse may misjudge.
This leads us to LCP, Google’s official metric addressing FMP’s ambiguity.
LCP (Largest Contentful Paint): When the Biggest Content Renders
LCP, or Largest Contentful Paint, is a Core Web Vitals metric measuring when the page’s largest content element (e.g., image, video, or text block) fully renders. It’s more precise than FMP, focusing on the largest visible element.
How to Measure LCP?
LCP is captured via the Performance API’s LargestContentfulPaint
entry. The browser tracks the largest content element in the viewport and records its render time. Here’s the code:
const observer = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
for (const entry of entries) {
console.log(`LCP: ${entry.startTime.toFixed(2)}ms`);
console.log('Element:', entry.element);
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
This code:
- Uses
PerformanceObserver
to monitorlargest-contentful-paint
events. -
entry.startTime
is the LCP time. -
entry.element
is the DOM element triggering LCP (e.g.,<img>
or<div>
).
You can also see LCP in Chrome DevTools’ Performance panel. Open DevTools (F12), go to the “Performance” tab, record a page load, and LCP will be marked on the timeline.
LCP Definition
LCP tracks elements like:
-
<img>
(including<picture>
’s<img>
). -
<video>
(poster images). - Elements with background images (via
url()
). - Text blocks (e.g.,
<p>
,<h1>
). - Containers like
<div>
(if they contain text or images).
The browser:
- Tracks the largest content element in the viewport.
- Records the time when it fully renders (e.g., image loads or text paints).
- Updates LCP if a larger element appears later (e.g., a new image loads after scrolling).
Code Example
Here’s a page highlighting LCP:
<!DOCTYPE html>
<html>
<head>
<title>LCP Demo</title>
<style>
.hero {
width: 100%;
height: 400px;
background: url('hero.jpg') no-repeat center/cover;
}
h1 {
font-size: 48px;
}
</style>
</head>
<body>
<div class="hero"></div>
<h1>Welcome to My Site</h1>
<p>Some content...</p>
</body>
</html>
Assuming hero.jpg
is a large image (e.g., 1920×1080), the rendering order might be:
- FP: Page background color (default white) paints.
- FMP:
<h1>
title renders (likely the main content). - LCP:
.hero
background image fully loads and paints (largest viewport element).
Verify with:
const observer = new PerformanceObserver((entryList) => {
const lcp = entryList.getEntries().pop();
console.log(`LCP: ${lcp.startTime.toFixed(2)}ms`);
console.log('LCP Element:', lcp.element);
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
You’ll likely see the LCP element as <div class="hero">
, with timing depending on hero.jpg
’s load speed.
LCP Considerations
- Image Loading: If LCP is an image, use modern formats (WebP, AVIF) and compress file sizes.
- Font Loading: For text-based LCP, slow font files (WOFF2) can delay rendering.
- Dynamic Content: In SPAs, LCP may occur after React/Vue renders the main component.
CLS (Cumulative Layout Shift): How Stable Is the Page?
CLS, or Cumulative Layout Shift, measures how much a page’s content “jumps” or shifts during loading. Users hate clicking a button only for the page to shift, causing a misclick. CLS is a Core Web Vitals metric aimed at ensuring page stability.
How to Measure CLS?
CLS is calculated via LayoutShift
performance entries. The browser tracks non-user-initiated layout changes and computes a “shift score.” Here’s the code:
let clsScore = 0;
const observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) { // Ignore user-triggered shifts
clsScore += entry.value;
console.log(`Layout Shift: ${entry.value.toFixed(4)}`);
}
}
console.log(`Cumulative Layout Shift: ${clsScore.toFixed(4)}`);
});
observer.observe({ type: 'layout-shift', buffered: true });
-
entry.value
is the score for a single layout shift. -
clsScore
sums all shifts for the total CLS. -
hadRecentInput
excludes shifts from user interactions (e.g., clicks, scrolls).
A CLS score below 0.1 is ideal, per Google. Use Chrome DevTools’ Performance panel to visualize CLS, with shifting elements highlighted.
CLS Calculation
CLS is the sum of individual shift scores, each calculated as:
- Impact Fraction: The proportion of the viewport affected by the shift. E.g., an element moving from top to bottom might affect 0.5 of the viewport.
- Distance Fraction: The distance moved relative to viewport height. E.g., moving 10% of the viewport height gives a distance fraction of 0.1.
CLS = Impact Fraction × Distance Fraction
Each shift contributes to the total CLS during page load.
Code Example
Here’s a page causing CLS:
<!DOCTYPE html>
<html>
<head>
<title>CLS Demo</title>
<style>
.banner {
width: 100%;
height: 100px;
background: lightblue;
}
.content {
margin-top: 10px;
}
</style>
</head>
<body>
<div class="content">
<h1>Main Content</h1>
<p>This content might shift...</p>
</div>
<script>
// Simulate async ad loading
setTimeout(() => {
const banner = document.createElement('div');
banner.className = 'banner';
document.body.insertBefore(banner, document.body.firstChild);
}, 2000);
</script>
</body>
</html>
This page:
- Initially renders
<h1>
and<p>
. - After 2 seconds, JS inserts a 100px-high
.banner
, shifting.content
downward.
CLS calculation:
-
Impact Fraction:
.content
occupies most of the viewport (e.g., 0.8). -
Distance Fraction:
.banner
’s 100px height in a 1000px viewport yields 0.1. - CLS ≈ 0.8 × 0.1 = 0.08.
Verify with:
let clsScore = 0;
const observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
clsScore += entry.value;
console.log(`CLS: ${clsScore.toFixed(4)}`);
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
CLS will be around 0.08. Additional shifts (e.g., images resizing after loading) increase CLS.
Common CLS Causes
-
Images Without Dimensions:
<img>
tags withoutwidth
andheight
expand when loaded. - Async Content: Ads, iframes, or dynamically inserted components.
- Font Loading: Web fonts loading slowly cause layout shifts when switching from system fonts.
Fixing CLS
Add dimensions to images:
<img src="image.jpg" width="800" height="600" alt="Example">
Use CSS placeholders:
.banner {
width: 100%;
height: 100px; /* Reserve space */
}
Real-World Scenario: Comprehensive Analysis
Let’s analyze a realistic page with FP, FMP, LCP, and CLS:
<!DOCTYPE html>
<html>
<head>
<title>News Site</title>
<link rel="stylesheet" href="styles.css">
<link href="https://fonts.googleapis.com/css2?family=Roboto" rel="stylesheet">
</head>
<body>
<header>
<h1>News Site</h1>
</header>
<main>
<img src="hero.jpg" alt="Hero image">
<h2>Top Story</h2>
<p>Breaking news content...</p>
</main>
<script src="ads.js"></script>
</body>
</html>
/* styles.css */
body {
background-color: #f0f0f0;
}
header {
background: navy;
color: white;
padding: 10px;
}
img {
width: 100%;
height: auto;
}
main {
margin: 20px;
}
h2, p {
font-family: 'Roboto', sans-serif;
}
// ads.js
setTimeout(() => {
const ad = document.createElement('div');
ad.style.height = '100px';
ad.style.background = 'lightgreen';
document.body.insertBefore(ad, document.body.firstChild);
}, 3000);
Measure performance:
// Comprehensive monitoring
window.addEventListener('load', () => {
// FP
const paintEntries = performance.getEntriesByType('paint');
const fp = paintEntries.find(entry => entry.name === 'first-paint');
console.log(`FP: ${fp ? fp.startTime.toFixed(2) : 'N/A'}ms`);
// LCP
new PerformanceObserver((entryList) => {
const lcp = entryList.getEntries().pop();
console.log(`LCP: ${lcp.startTime.toFixed(2)}ms, Element:`, lcp.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// CLS
let clsScore = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
clsScore += entry.value;
console.log(`CLS: ${clsScore.toFixed(4)}`);
}
}
}).observe({ type: 'layout-shift', buffered: true });
});
Expected results:
-
FP:
body
background color renders (~200ms). -
FMP (via Lighthouse):
<h2>
and<p>
render (~500ms, affected by font loading). -
LCP:
<img src="hero.jpg">
fully loads (~1000ms, depending on image size and network). -
CLS: Ad insertion shifts
<main>
, CLS ~0.08.
Analysis
- FP: Fast, as it’s just the background color, independent of complex resources.
-
FMP: Font loading may delay; use
font-display: swap
to show system fonts first. -
LCP: Image is the bottleneck; use WebP, compress, or add
loading="lazy"
for non-viewport images. -
CLS: Ad insertion causes shifts; reserve space with
min-height: 100px
.
SPA Scenario: React App Performance
Single-page applications (SPAs) using React/Vue complicate performance metrics due to dynamic rendering. Here’s a simple React page to analyze FP, FMP, LCP, and CLS:
// src/App.js
import React, { useEffect, useState } from 'react';
function App() {
const [ad, setAd] = useState(null);
useEffect(() => {
setTimeout(() => {
setAd(<div style={{ height: '100px', background: 'lightgreen' }}>Ad</div>);
}, 3000);
}, []);
return (
<div>
{ad}
<header>
<h1>React News</h1>
</header>
<main>
<img src="hero.jpg" alt="Hero" style={{ width: '100%' }} />
<h2>Top Story</h2>
<p>Breaking news content...</p>
</main>
</div>
);
}
export default App;
/* src/App.css */
body {
background-color: #f0f0f0;
}
header {
background: navy;
color: white;
padding: 10px;
}
main {
margin: 20px;
}
Run with Create React App:
npx create-react-app react-news
cd react-news
npm start
Add performance monitoring (as above). Expected results:
- FP: React’s initial render of the background color (~300ms).
-
FMP:
<h1>
and<h2>
render (~600ms, affected by JS parsing/execution). -
LCP:
<img>
loads (~1200ms). - CLS: Ad insertion causes a shift, CLS ~0.08.
SPA Challenges
- JS Blocking: Large React JS files delay FMP and LCP due to parsing/execution.
-
Dynamic Rendering: Content loaded via
useEffect
or async requests delays LCP. - CLS Risk: Dynamic components (ads, comments) increase layout shifts.
Improvements
- Code Splitting:
import React, { lazy, Suspense } from 'react';
const HeroImage = lazy(() => import('./HeroImage'));
function App() {
return (
<div>
<header>
<h1>React News</h1>
</header>
<main>
<Suspense fallback={<div>Loading...</div>}>
<HeroImage />
</Suspense>
<h2>Top Story</h2>
<p>Breaking news content...</p>
</main>
</div>
);
}
- Reserve Space:
.ad-placeholder {
min-height: 100px;
}
{ad || <div className="ad-placeholder"></div>}
Tool Support
- Chrome DevTools: Performance panel visually displays FP, FMP, LCP, and CLS.
- Lighthouse: Runs performance audits, providing FMP and LCP times.
- Web Vitals Library: Google’s lightweight library simplifies LCP and CLS monitoring:
import { getLCP, getCLS } from 'web-vitals';
getLCP((metric) => console.log('LCP:', metric.value.toFixed(2), 'ms'));
getCLS((metric) => console.log('CLS:', metric.value.toFixed(4)));
Install:
npm install web-vitals
Comprehensive Case: Optimizing a Complex Page
Here’s a complex page with images, fonts, ads, and dynamic content, analyzing all metrics:
<!DOCTYPE html>
<html>
<head>
<title>Complex Site</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto" rel="stylesheet">
<style>
body {
background-color: #f0f0f0;
}
.hero {
width: 100%;
height: 400px;
background: url('hero.jpg') no-repeat center/cover;
}
.ad {
min-height: 100px;
}
main {
margin: 20px;
}
h1, h2, p {
font-family: 'Roboto', sans-serif;
}
</style>
</head>
<body>
<div class="ad"></div>
<header>
<h1>Complex Site</h1>
</header>
<main>
<div class="hero"></div>
<h2>Top Story</h2>
<p>Content loaded dynamically...</p>
</main>
<script>
setTimeout(() => {
document.querySelector('.ad').innerHTML = '<div style="height: 100px; background: lightgreen;">Ad</div>';
document.querySelector('p').innerText = 'Dynamic content loaded!';
}, 2000);
</script>
<script>
// Performance monitoring
window.addEventListener('load', () => {
const paintEntries = performance.getEntriesByType('paint');
const fp = paintEntries.find(entry => entry.name === 'first-paint');
console.log(`FP: ${fp ? fp.startTime.toFixed(2) : 'N/A'}ms`);
});
new PerformanceObserver((entryList) => {
const lcp = entryList.getEntries().pop();
console.log(`LCP: ${lcp.startTime.toFixed(2)}ms, Element:`, lcp.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
let clsScore = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
clsScore += entry.value;
console.log(`CLS: ${clsScore.toFixed(4)}`);
}
}
}).observe({ type: 'layout-shift', buffered: true });
</script>
</body>
</html>
Analysis:
-
FP:
body
background color (~200ms). -
FMP (Lighthouse estimate):
<h1>
or.hero
(~600ms, affected by fonts/CSS). -
LCP:
.hero
background image (~1000ms). -
CLS: Ad insertion, CLS ~0.0 (due to
min-height
reservation).
Conclusion (Technical Details)
FP, FMP, LCP, and CLS are key frontend performance metrics:
- FP: First paint, capturing the rendering start.
- FMP: First meaningful paint, estimating when useful content appears.
- LCP: Largest contentful paint, precisely measuring main element load time.
- CLS: Cumulative layout shift, ensuring page stability.
Using the Performance API, Lighthouse, and Web Vitals, you can accurately measure these metrics. The code examples demonstrate monitoring and analysis, with real-world cases (static pages, SPAs, complex pages) clarifying each metric’s trigger and impact. Try these scripts, run DevTools, and dive into the fun of performance analysis!
This content originally appeared on DEV Community and was authored by Tianya School