Frontend Performance Metrics FP, FMP, LCP, and CLS Explained



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 monitor largest-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 without width and height 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