LCP - Largest Contentful Paint
Measures loading performance
What is LCP?
LCP measures how long it takes for the largest visible content element to render on the screen. It answers the user's question: "When did the main content of this page load?"
| LCP Time | Rating |
|---|---|
| โค 2.5 seconds | ๐ข Good |
| 2.5 - 4.0 seconds | ๐ก Needs Improvement |
| > 4.0 seconds | ๐ด Poor |
What Elements Can Be the LCP?
<img>elements<image>inside<svg><video>elements (poster image)- Elements with
background-imagevia CSS - Block-level text elements (
<h1>,<p>, etc.)
What Causes Poor LCP?
1. Slow Server Response Time
The browser can't render anything until it receives HTML from your server.
- Slow database queries
- No server-side caching
- Server located far from users (no CDN)
- Overloaded server / insufficient resources
2. Render-Blocking Resources
The browser stops rendering when it encounters certain resources:
<head>
<!-- These BLOCK rendering until fully downloaded & parsed -->
<link rel="stylesheet" href="styles.css">
<script src="app.js"></script>
</head>
3. Slow Resource Load Times
- Large, unoptimized images (not compressed, wrong format)
- Images served from slow origins (no CDN)
- No priority hints (browser doesn't know it's important)
- Background images loaded via CSS (discovered late)
4. Client-Side Rendering
How to Measure LCP
Lab Tools
- Chrome DevTools - Performance Panel
- Chrome DevTools - Lighthouse
- Web Vitals Chrome Extension
Field Tools
- Google Search Console (Core Web Vitals Report)
- PageSpeed Insights
- Chrome UX Report (CrUX)
Measuring in Code
// Basic LCP measurement using Performance Observer API
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
// LCP can change as page loads, so we want the last one
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.startTime, 'ms');
console.log('LCP Element:', lastEntry.element);
console.log('LCP Size:', lastEntry.size);
});
observer.observe({
type: 'largest-contentful-paint',
buffered: true // get entries from before observer was created
});
Paste this in console to highlight the LCP element:
new PerformanceObserver((list) => {
const lcp = list.getEntries().pop();
lcp.element.style.outline = '5px solid red';
}).observe({ type: 'largest-contentful-paint', buffered: true });
How to Optimize LCP
1. Preload the LCP Image
<head>
<!-- Tell browser: "This image is critical, start loading NOW!" -->
<link
rel="preload"
as="image"
href="/hero-image.webp"
>
</head>
2. Use fetchpriority Attribute
<!-- High priority for LCP image -->
<img
src="hero.webp"
alt="Hero"
fetchpriority="high"
>
<!-- Low priority for below-fold images -->
<img
src="footer.webp"
alt="Footer"
fetchpriority="low"
loading="lazy"
>
3. Inline Critical CSS
<head>
<!-- Critical CSS inline - renders immediately, no network request -->
<style>
/* Only above-the-fold styles */
.header { ... }
.hero { ... }
</style>
<!-- Load full CSS without blocking rendering -->
<link
rel="preload"
href="styles.css"
as="style"
onload="this.rel='stylesheet'"
>
</head>
4. Defer Non-Critical JavaScript
<!-- defer: Doesn't block, executes after HTML parsing -->
<script src="app.js" defer></script>
<!-- async: Doesn't block, executes as soon as downloaded -->
<script src="analytics.js" async></script>
5. Optimize Web Fonts
<head>
<!-- Preload critical fonts -->
<link
rel="preload"
href="/fonts/main.woff2"
as="font"
type="font/woff2"
crossorigin <!-- Required for fonts! -->
>
</head>
<style>
@font-face {
font-family: 'MyFont';
src: url('/fonts/main.woff2') format('woff2');
/* swap: Show fallback immediately, swap when font loads */
font-display: swap;
}
</style>
- Add
<link rel="preload">for LCP image - Add
fetchpriority="high"to LCP image - Convert images to WebP/AVIF
- Inline critical CSS
- Add
deferto non-critical scripts - Use
font-display: swapfor fonts - Set up a CDN
- Enable compression (gzip/brotli)
FID - First Input Delay
Measures interactivity
What is FID?
FID measures the delay between when a user first interacts with your page and when the browser can respond. It answers: "Why isn't this button working when I click it?"
FID has been replaced by INP (Interaction to Next Paint) as of March 2024. However, the concepts still apply.
| FID Time | Rating |
|---|---|
| โค 100 ms | ๐ข Good |
| 100 - 300 ms | ๐ก Needs Improvement |
| > 300 ms | ๐ด Poor |
The Core Problem
FID is almost always caused by JavaScript blocking the main thread. When JavaScript is executing, the browser cannot respond to user input.
What Counts as "First Input"?
- Clicks
- Taps
- Key presses
- Scrolling
- Zooming
- Pinching
CLS - Cumulative Layout Shift
Measures visual stability
What is CLS?
CLS measures how much page content unexpectedly moves around while loading. It answers the user's frustration: "I was about to click that button and it moved!"
| CLS Score | Rating |
|---|---|
| โค 0.1 | ๐ข Good |
| 0.1 - 0.25 | ๐ก Needs Improvement |
| > 0.25 | ๐ด Poor |
CLS is a unitless score, not milliseconds. It's calculated as: CLS = Impact Fraction ร Distance Fraction
The Classic CLS Problem
What Causes Poor CLS?
1. Images Without Dimensions
<!-- No dimensions = 0 initial height -->
<img src="photo.jpg">
<img
src="photo.jpg"
width="800"
height="600"
>
2. Ads/Embeds Without Reserved Space
Ads load late and dynamically inject content, shifting everything below.
3. Web Fonts Causing Text Reflow
Different fonts have different sizes, causing text to reflow when custom font loads.
4. Dynamically Injected Content
- Cookie consent banners
- Newsletter signup popups
- "Breaking news" bars
- Notification banners
5. Animations That Trigger Layout
element.style.height = '200px';
element.style.top = '50px';
element.style.margin = '10px';
element.style.transform =
'translateY(50px)';
element.style.opacity = '0.5';
How to Measure CLS
Debug: Highlight Shifting Elements
// Paste in console to highlight elements that shift
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Skip user-initiated shifts
if (entry.hadRecentInput) continue;
entry.sources?.forEach((source) => {
if (source.node) {
source.node.style.outline = '3px solid red';
console.log('Shifted:', source.node);
}
});
}
}).observe({ type: 'layout-shift', buffered: true });
How to Optimize CLS
1. Always Set Image Dimensions
<!-- Option 1: Explicit width and height -->
<img
src="photo.jpg"
width="800"
height="600"
>
<!-- Option 2: CSS aspect-ratio -->
<style>
.image {
width: 100%;
aspect-ratio: 16 / 9; /* Reserve space */
object-fit: cover;
}
</style>
2. Reserve Space for Ads
.ad-slot {
/* Reserve minimum space based on ad size */
min-height: 250px;
width: 300px;
background-color: #f5f5f5;
}
3. Prevent Font Layout Shifts
@font-face {
font-family: 'BrandFont';
src: url('/fonts/brand.woff2') format('woff2');
/* 'optional' = best for CLS, only uses font if cached */
font-display: optional;
}
4. Use Fixed Positioning for Banners
.cookie-banner {
position: fixed; /* Removed from document flow */
bottom: 0;
left: 0;
right: 0;
/* Animate with transform - no CLS */
transform: translateY(0);
transition: transform 0.3s;
}
.cookie-banner.hidden {
transform: translateY(100%);
}
5. Handle Unknown Image Dimensions
// Detect dimensions before adding to DOM
function loadImage(src, container) {
const img = new Image();
img.onload = function() {
// Set aspect ratio from natural dimensions
const ratio = this.naturalWidth / this.naturalHeight;
container.style.aspectRatio = ratio;
// Now add image - no layout shift!
container.appendChild(this);
};
img.src = src;
}
- Add
width/heightto ALL images - Add
aspect-ratioto image containers - Reserve space for ad slots with
min-height - Wrap iframes in aspect-ratio containers
- Use
font-display: optionalfor fonts - Use fixed/absolute positioning for banners
- Implement skeleton screens
- Use
transforminstead of layout properties for animations