LCP Element Identification: Finding Your Actual LCP Target

Find your actual LCP element using Chrome DevTools and PerformanceObserver API. Learn why the LCP target is often not the hero image you expect.

BugViso

15 min read

A frontend team spends two weeks optimizing their hero image — converting to AVIF, adding fetchpriority="high", implementing responsive srcset breakpoints, and preloading in the <head>. They deploy, wait for CrUX field data, and discover their LCP score barely moved. The hero image wasn't the LCP element. The actual LCP target was a text heading rendered in a custom web font that didn't finish loading until 3.2 seconds after navigation — a completely different optimization target requiring a completely different fix.

This misidentification problem is widespread. Studies from web performance consultancies show that 40% of developers optimize the wrong element when trying to improve LCP, because they assume the LCP element is the largest visual element they see — when it's actually the largest element painted within the LCP measurement window, which is a different thing entirely.

This guide shows how to conclusively identify your actual LCP element using Chrome DevTools, the PerformanceObserver API, and automated crawl data — then how to apply the correct optimization for each LCP element type.


What Qualifies as an LCP Candidate Element

The Largest Contentful Paint specification (defined in the Element Timing API) limits LCP candidates to specific element types. Not everything visible on the page is eligible:

Element TypeLCP Candidate?Example
<img> elements✅ YesHero images, product photos
<image> inside <svg>✅ YesSVG illustrations with embedded rasters
<video> poster image✅ YesVideo thumbnails
CSS background-image (via url())✅ YesHero banners set via CSS
Block-level text elements (<h1>, <p>, <div> with text)✅ YesHeadlines, body paragraphs
<canvas> elements❌ NoChart.js visualizations, WebGL
<svg> (vector-only, no <image>)❌ NoIcon SVGs, vector logos
<video> (playing video frames)❌ NoAuto-playing background video
Inline elements (<span>, <a>)❌ No (unless block-level)Inline links, highlighted text

The LCP element is the candidate with the largest rendered area (width × height in CSS pixels) at the time it's painted. The browser continuously updates the LCP candidate as new elements paint — the final LCP entry when the page becomes idle (or when the user first interacts) is reported.

💡 Engineering Rule of Thumb: The LCP element can change during page load. An <h1> heading might be the LCP element initially (painted first), but a hero image that loads later and occupies a larger area replaces it. The final LCP entry reported is the one that matters for Core Web Vitals scoring.


Method 1: Chrome DevTools Performance Tab

The most reliable visual method for identifying your LCP element:

text
Step-by-step LCP identification in Chrome DevTools:

1. Open Chrome DevTools (F12 or Cmd+Option+I)
2. Navigate to the Performance tab
3. Click the ⚙️ gear icon and enable:
   - "Screenshots" checkbox
   - "Web Vitals" checkbox
4. Clear browser cache (important for accurate first-load measurement)
5. Click the Record button (⏺)
6. Reload the page (Cmd+R / Ctrl+R) while recording
7. Wait for the page to fully load, then Stop recording

8. In the Timings lane, look for the green "LCP" marker
9. Click the LCP marker → it highlights the LCP element in the DOM
10. The Summary panel shows:
    - Element: the actual DOM element (e.g., <img>, <h1>, <p>)
    - Size: rendered pixel dimensions
    - URL: image source (for image LCP elements)
    - Timing: exact LCP timestamp in milliseconds

Common Surprises When Identifying LCP

What You ExpectedWhat LCP Actually WasWhy
Hero image (1200×600)<h1> heading (1200×80)Image lazy-loaded or loaded after user's first interaction
Product photoBackground gradient <div>Photo loaded asynchronously; the <div> with CSS gradient painted first and was never replaced
Featured bannerNavigation logoBanner was below the fold on mobile viewport
Video thumbnailParagraph text below videoVideo poster image had loading="lazy"

Method 2: PerformanceObserver API (Programmatic)

For automated identification across multiple pages or for RUM monitoring:

typescript
// Identify the LCP element programmatically
const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries()
  // The last entry is the final LCP candidate
  const lastEntry = entries[entries.length - 1] as any

  console.log('=== LCP Element Identification ===')
  console.log(`LCP Time: ${lastEntry.startTime.toFixed(0)}ms`)
  console.log(`Element: ${lastEntry.element?.tagName || 'unknown'}`)
  console.log(`Element ID: ${lastEntry.element?.id || 'none'}`)
  console.log(`Element Class: ${lastEntry.element?.className || 'none'}`)
  console.log(`Size: ${lastEntry.size} pixels²`)
  console.log(`URL: ${lastEntry.url || 'N/A (text element)'}`)
  console.log(`Render Time: ${lastEntry.renderTime.toFixed(0)}ms`)
  console.log(`Load Time: ${lastEntry.loadTime.toFixed(0)}ms`)

  // Get a CSS selector for the LCP element
  if (lastEntry.element) {
    const el = lastEntry.element
    const selector = getCSSSelector(el)
    console.log(`CSS Selector: ${selector}`)

    // Highlight the element visually
    el.style.outline = '4px solid red'
    el.style.outlineOffset = '2px'
  }
})

lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true })

// Helper: generate a unique CSS selector for any element
function getCSSSelector(el: Element): string {
  if (el.id) return `#${el.id}`

  const parts: string[] = []
  let current: Element | null = el

  while (current && current !== document.body) {
    let selector = current.tagName.toLowerCase()
    if (current.id) {
      selector = `#${current.id}`
      parts.unshift(selector)
      break
    }
    if (current.className && typeof current.className === 'string') {
      const classes = current.className.trim().split(/\s+/).slice(0, 2)
      selector += `.${classes.join('.')}`
    }
    const parent = current.parentElement
    if (parent) {
      const siblings = Array.from(parent.children).filter(
        c => c.tagName === current!.tagName
      )
      if (siblings.length > 1) {
        const index = siblings.indexOf(current) + 1
        selector += `:nth-of-type(${index})`
      }
    }
    parts.unshift(selector)
    current = current.parentElement
  }

  return parts.join(' > ')
}

Logging LCP Across Multiple Pages

typescript
// Playwright script to identify LCP element across multiple pages
import { chromium } from 'playwright'

const URLS = [
  'https://your-site.com/',
  'https://your-site.com/products',
  'https://your-site.com/blog',
  'https://your-site.com/pricing',
]

async function identifyLCP(url: string) {
  const browser = await chromium.launch()
  const page = await browser.newPage()

  // Inject LCP observer before navigation
  await page.addInitScript(() => {
    (window as any).__lcpEntries = []
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        (window as any).__lcpEntries.push({
          startTime: entry.startTime,
          size: (entry as any).size,
          element: (entry as any).element?.tagName,
          id: (entry as any).element?.id,
          url: (entry as any).url,
        })
      }
    })
    observer.observe({ type: 'largest-contentful-paint', buffered: true })
  })

  await page.goto(url, { waitUntil: 'networkidle' })
  await page.waitForTimeout(2000) // Allow late-loading elements

  const lcpData = await page.evaluate(() => {
    const entries = (window as any).__lcpEntries
    return entries[entries.length - 1] // Final LCP
  })

  console.log(`${url}`)
  console.log(`  LCP: ${lcpData?.startTime?.toFixed(0)}ms`)
  console.log(`  Element: ${lcpData?.element}`)
  console.log(`  URL: ${lcpData?.url || 'text element'}`)
  console.log(`  Size: ${lcpData?.size} px²`)
  console.log()

  await browser.close()
}

// Run across all pages
for (const url of URLS) {
  await identifyLCP(url)
}

Method 3: Lighthouse and PageSpeed Insights

Both tools report the LCP element in their audit output:

bash
# Lighthouse CLI — outputs LCP element details
npx lighthouse https://your-site.com \
  --output=json \
  --only-audits=largest-contentful-paint \
  | jq '.audits["largest-contentful-paint"]'

# Output includes:
# {
#   "numericValue": 2340,
#   "details": {
#     "items": [{
#       "node": {
#         "type": "img",
#         "selector": "div.hero > img.hero-banner",
#         "snippet": "<img src=\"/images/hero.webp\" ...>"
#       }
#     }]
#   }
# }

Optimization Strategies by LCP Element Type

Once you've identified your LCP element, the optimization strategy depends entirely on its type:

LCP Type: Image (<img>, <picture>, CSS background-image)

html
<!-- Complete optimization stack for image LCP elements -->

<!-- 1. Preload in <head> for earliest possible download -->
<link
  rel="preload"
  href="/images/hero-800.webp"
  as="image"
  type="image/webp"
  imagesrcset="/images/hero-400.webp 400w, /images/hero-800.webp 800w, /images/hero-1200.webp 1200w"
  imagesizes="(max-width: 640px) 100vw, 1200px"
  fetchpriority="high"
/>

<!-- 2. Responsive image with AVIF/WebP format negotiation -->
<picture>
  <source
    srcset="/images/hero-400.avif 400w, /images/hero-800.avif 800w, /images/hero-1200.avif 1200w"
    sizes="(max-width: 640px) 100vw, 1200px"
    type="image/avif"
  />
  <source
    srcset="/images/hero-400.webp 400w, /images/hero-800.webp 800w, /images/hero-1200.webp 1200w"
    sizes="(max-width: 640px) 100vw, 1200px"
    type="image/webp"
  />
  <img
    src="/images/hero-800.jpg"
    alt="Product showcase"
    width="1200"
    height="630"
    fetchpriority="high"
    loading="eager"
    decoding="async"
  />
</picture>
OptimizationExpected LCP Reduction
Convert PNG → WebP-200–400ms
Convert PNG → AVIF-300–600ms
Add responsive srcset-200–800ms (mobile)
Remove loading="lazy"-100–300ms
Add fetchpriority="high"-50–200ms
Preload in <head>-100–300ms

LCP Type: Text (<h1>, <p>, <div> with text)

When text is the LCP element, the bottleneck is typically font loading:

css
/* 1. Use font-display: swap for immediate text visibility */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-latin-700.woff2') format('woff2');
  font-display: swap;
  font-weight: 700;
}

/* 2. Size-adjusted fallback to prevent CLS on swap */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107.64%;
  ascent-override: 96.88%;
  descent-override: 24.15%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter Fallback', sans-serif;
}
html
<!-- 3. Preload the font used by the LCP text element -->
<link
  rel="preload"
  href="/fonts/inter-latin-700.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>
OptimizationExpected LCP Reduction
font-display: swap-500–2000ms (eliminates FOIT block period)
Preload font WOFF2-100–300ms
Self-host fonts (vs Google Fonts CDN)-50–200ms
Subset to Latin-only-20–50ms (smaller file)

LCP Type: CSS Background Image

CSS background images are harder to optimize because they can't use srcset or fetchpriority:

html
<!-- Preload the background image explicitly -->
<link
  rel="preload"
  href="/images/hero-bg.webp"
  as="image"
  type="image/webp"
  fetchpriority="high"
/>
css
.hero {
  /* Use optimized format */
  background-image: url('/images/hero-bg.webp');
  background-size: cover;
  background-position: center;
  min-height: 500px;
}

💡 Engineering Rule of Thumb: If your LCP element is a CSS background-image, strongly consider migrating to an <img> tag with object-fit: cover. The <img> tag supports srcset, fetchpriority, loading, and is discoverable by the browser's preload scanner — none of which work with CSS background images.


LCP on Different Viewports: Mobile vs Desktop

The LCP element often differs between mobile and desktop because the viewport size changes which element occupies the most rendered pixels:

Diagram
Desktop (1440px viewport):
┌─────────────────────────────────────┐
│  Logo  │  Navigation  │  Search     │
├─────────────────────────────────────┤
│                                     │
│   ┌─────────────────────────────┐   │
│   │   HERO IMAGE (1200×600)     │   │  ← LCP on desktop
│   │   (largest rendered area)   │   │     (720,000 px²)
│   └─────────────────────────────┘   │
│                                     │
│   <h1>Product Title</h1>           │     (not LCP: 1200×48 = 57,600 px²)
└─────────────────────────────────────┘

Mobile (390px viewport):
┌───────────────────┐
│  ☰  Logo  🔍     │
├───────────────────┤
│                   │
│ ┌───────────────┐ │
│ │ HERO IMAGE    │ │  (350×184 = 64,400 px²)
│ │ (scaled down) │ │
│ └───────────────┘ │
│                   │
│ Product Title     │  ← LCP on mobile
│ (h1, full width)  │     (350×72 = 25,200 px²)
│ Description text  │     Wait — image is still larger...
│ that wraps across  │
│ multiple lines     │     Unless the image has loading="lazy"
│ filling the        │     and the <h1> paints first!
│ mobile viewport    │
└───────────────────┘

This is why you must test LCP identification on both mobile and desktop viewports. The optimization target may be completely different.

typescript
// Playwright: test LCP on both viewports
const viewports = [
  { name: 'Mobile', width: 390, height: 844, deviceScaleFactor: 3 },
  { name: 'Desktop', width: 1440, height: 900, deviceScaleFactor: 1 },
]

for (const vp of viewports) {
  const context = await browser.newContext({
    viewport: { width: vp.width, height: vp.height },
    deviceScaleFactor: vp.deviceScaleFactor,
  })
  const page = await context.newPage()
  // ... inject LCP observer and measure
  console.log(`${vp.name} LCP element: ${lcpData.element}`)
}

How BugViso Identifies LCP Elements Across Your Entire Site

Manually running DevTools LCP identification on every important page is feasible for 5–10 pages. For a 50-page site with both mobile and desktop variants, it requires 100 individual tests — each taking 2–3 minutes of setup, recording, and analysis.

BugViso's multi-page crawl engine automates LCP identification across your entire site:

  • Per-page LCP capture using the official web-vitals library injected into a headless Chromium session, reporting the LCP timestamp in milliseconds for every crawled page — identifying which pages have the slowest LCP.
  • Site-wide LCP aggregation showing average and worst-page LCP across the entire crawl, prioritizing optimization effort on the pages that matter most.
  • Mobile device emulation that re-scans under mobile viewport conditions (default: Pixel 5), capturing mobile-specific LCP separately — identifying cases where the LCP element differs between desktop and mobile.
  • Network throttling simulation under Slow 3G and Fast 3G profiles, revealing LCP regressions that only appear on constrained connections where image and font downloads take significantly longer.
  • Image optimization simulation via the Advanced Speed & Performance Engine, which downloads large images and re-encodes through WebP and AVIF — quantifying the exact byte savings available for image-type LCP elements.

Frequently Asked Questions

Can the LCP element be below the fold?

No. LCP only considers elements that are visible within the initial viewport (above the fold) at the time they paint. Elements that require scrolling to see are not LCP candidates. This is why the LCP element on mobile (smaller viewport) is often different from desktop (larger viewport).

Does LCP change after user interaction?

Yes, but only the LCP value recorded before the first user input is reported. Once the user clicks, taps, or scrolls, the browser stops updating the LCP candidate. This means an image that loads 5 seconds after the user first scrolls does not affect the reported LCP score.

Why does my LCP element keep changing between measurements?

Non-deterministic LCP elements are usually caused by race conditions: an image and a text heading compete for the LCP position, and whichever paints first (which depends on network timing) becomes the LCP element. Stabilize this by preloading the image so it always paints before the font-dependent heading renders.

Is a text LCP element better or worse than an image LCP element?

Neither is inherently better. Text LCP elements tend to have faster LCP scores because text paints immediately once the font is available (or immediately in fallback with font-display: swap). Image LCP elements depend on image download time, which varies with file size and network speed. The optimal choice depends on your design — but if your LCP is a text element, ensure the font loads fast. See font loading strategies for CLS and LCP for the complete font optimization guide.

How do I force a specific element to be the LCP target?

You can't directly control which element the browser selects as the LCP candidate — it's always the largest rendered candidate element. However, you can influence the outcome by ensuring your intended LCP element renders at a larger size than competing elements and loads before them. Use fetchpriority="high" and preloading to ensure image LCP elements paint early. A free BugViso audit reports per-page LCP timings across your entire site, identifying which pages need the most attention.

Found this useful? Share it.

See where your site stands

Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.