Skip to content

Split View: Core Web Vitals 실제로 고치기 — LCP, INP, CLS를 숫자로 내리는 순서

✨ Learn with Quiz
|

Core Web Vitals 실제로 고치기 — LCP, INP, CLS를 숫자로 내리는 순서

들어가며 — 라이트하우스는 98점인데 Search Console은 빨간불일 때

로컬에서 라이트하우스를 돌리면 성능 98점입니다. 배포하고 일주일 뒤 Search Console을 열면 "개선이 필요한 URL"에 그 페이지가 그대로 있습니다. 팀에서는 "구글 데이터가 늦게 반영되나 보다"라고 넘어가고, 한 달 뒤에도 상황은 같습니다.

두 숫자는 서로 다른 것을 재고 있습니다. 라이트하우스는 통제된 환경에서 한 번 로드한 결과이고, Search Console이 보여주는 것은 실제 방문자들의 28일치 분포입니다. 전자를 개선했다고 후자가 따라오지 않는 경우가 아주 흔하고, 그 간극이 어디서 생기는지를 아는 것이 이 작업의 절반입니다.

이 글은 그 간극을 짚고, 세 지표를 실제로 내리는 작업을 효과가 큰 순서대로 다룹니다.

LCP, INP, CLS가 각각 측정하는 것과 임계값

세 지표는 사용자 경험의 서로 다른 국면을 담당합니다. LCP는 로딩, INP는 반응성, CLS는 시각적 안정성입니다.

지표재는 것좋음 기준실험실에서 측정가장 흔한 원인
LCP최대 콘텐츠 요소가 그려진 시점2.5초 이하가능느린 TTFB, 늦게 발견되는 히어로 이미지
INP상호작용부터 다음 페인트까지200ms 이하불가 (TBT로 대리)긴 태스크, 무거운 DOM 업데이트
CLS예기치 않은 레이아웃 이동의 누적 점수0.1 이하부분적크기 없는 이미지, 늦게 삽입되는 배너

여기서 대부분의 글이 빼먹는 두 가지를 짚습니다.

첫째, 이 임계값은 평균이 아니라 75백분위에 적용됩니다. 페이지 로드의 75%가 LCP 2.5초 이하여야 "좋음"입니다. 평균 LCP가 2.1초여도 25%가 4초를 넘으면 실패입니다. 앞서 백분위 이야기가 그대로 반복됩니다.

둘째, 모바일과 데스크톱은 따로 평가됩니다. 데스크톱만 보고 통과했다고 판단하는 경우가 잦은데, 트래픽 대부분이 모바일이라면 개선해야 할 것은 모바일 숫자입니다.

CLS 계산 방식도 한 번 짚고 갑니다. CLS는 페이지 수명 전체의 단순 합이 아닙니다. 이동이 발생한 구간을 최대 5초, 이동 간 간격 1초 이내로 묶어 세션 창을 만들고, 그중 합이 가장 큰 창의 값을 씁니다. 무한 스크롤 페이지가 오래 열려 있어도 점수가 무한히 커지지 않는 이유가 이것입니다.

실험실 데이터와 실사용자 데이터 — 어느 쪽이 진실인가

라이트하우스는 단말과 네트워크를 시뮬레이션해 한 번 로드합니다. 캐시는 비어 있고, 서드파티 스크립트는 그날의 응답 속도에 좌우되고, 사용자는 아무것도 클릭하지 않습니다. 그래서 라이트하우스는 INP를 측정할 수 없습니다. 상호작용이 없기 때문입니다. 대신 총 블로킹 시간(TBT)을 대리 지표로 보여 주는데, 상관은 있지만 같은 값이 아닙니다.

실사용자 데이터는 반대입니다. 오래된 안드로이드 단말, 3G 구간, 이미 캐시가 있는 재방문, 광고 차단기, 그리고 실제 클릭이 전부 섞여 있습니다. 검색 평가에 쓰이는 것도, 사용자가 실제로 겪는 것도 이쪽입니다.

결론은 단순합니다. 실험실 데이터는 회귀 감지용이고, 판단의 기준은 실사용자 데이터입니다. 라이트하우스 점수를 목표로 삼으면 점수는 오르고 사용자 경험은 그대로인 최적화를 하게 됩니다.

자체 RUM 수집은 스크립트 한 조각이면 시작할 수 있습니다.

// web-vitals v4 — attribution 빌드를 쓰면 "무엇이" 느렸는지까지 함께 온다
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution'

function send(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: Math.round(metric.value),
    rating: metric.rating, // good | needs-improvement | poor
    nav_type: metric.navigationType,
    path: location.pathname,
    // 디바이스 구분 없이 합치면 아무것도 진단할 수 없다
    device: matchMedia('(max-width: 768px)').matches ? 'mobile' : 'desktop',
    conn: navigator.connection?.effectiveType,
    attribution: metric.attribution,
  })
  navigator.sendBeacon('/rum/vitals', body)
}

onLCP(send)
onINP(send)
onCLS(send)
onTTFB(send)

수집한 뒤에는 반드시 경로별, 디바이스별로 75백분위를 봅니다. 전체 평균 하나로는 어느 페이지를 고쳐야 하는지 알 수 없습니다.

LCP를 내리는 순서 — 서버 응답부터 폰트까지

LCP를 통째로 보면 손댈 곳이 보이지 않습니다. 네 조각으로 쪼개면 답이 나옵니다.

  1. TTFB — 서버가 첫 바이트를 보내기까지
  2. 리소스 로드 지연 — TTFB 이후 LCP 리소스 요청이 시작되기까지
  3. 리소스 로드 시간 — 그 리소스를 다 받기까지
  4. 렌더 지연 — 다 받고 나서 실제로 화면에 그려지기까지
import { onLCP } from 'web-vitals/attribution'

onLCP(({ value, attribution: a }) => {
  console.table({
    TTFB: Math.round(a.timeToFirstByte),
    '리소스 로드 지연': Math.round(a.resourceLoadDelay),
    '리소스 로드 시간': Math.round(a.resourceLoadDuration),
    '렌더 지연': Math.round(a.elementRenderDelay),
    합계: Math.round(value),
    요소: a.target,
  })
})
// ┌──────────────────┬───────┐
// │ TTFB             │   412 │
// │ 리소스 로드 지연  │  1180 │  <-- 범인
// │ 리소스 로드 시간  │   340 │
// │ 렌더 지연        │    96 │
// │ 합계             │  2028 │
// └──────────────────┴───────┘

목표 배분은 TTFB와 리소스 로드 시간이 각각 40% 정도이고, 두 지연 항목은 0에 가까워야 합니다. 위 예시에서 로드 지연이 1,180ms라는 것은 브라우저가 히어로 이미지의 존재를 1초 넘게 몰랐다는 뜻입니다. 이미지 파일을 아무리 최적화해도 이 숫자는 줄지 않습니다.

TTFB부터 확인한다

curl -s -o /dev/null \
  -w 'dns:%{time_namelookup}s  tcp:%{time_connect}s  tls:%{time_appconnect}s  ttfb:%{time_starttransfer}s  total:%{time_total}s\n' \
  https://shop.example.com/products/12345
# dns:0.021s  tcp:0.043s  tls:0.118s  ttfb:0.412s  total:0.588s

# 캐시 적중 여부까지 함께 본다 — CDN 미스가 TTFB의 흔한 원인이다
curl -sI https://shop.example.com/products/12345 | grep -iE 'cache|age|server-timing'
# cache-control: public, max-age=0, s-maxage=300
# age: 0
# x-cache: MISS
# server-timing: db;dur=284, render;dur=96

Server-Timing 헤더를 서버에서 내려주면 RUM 데이터에서 TTFB의 내부 구성을 볼 수 있습니다. 이것 없이는 TTFB가 느릴 때 네트워크인지 백엔드인지 구분할 방법이 없습니다.

리소스 로드 지연이 가장 큰 지렛대다

LCP 요소가 이미지라면 프리로드 스캐너가 찾을 수 있어야 합니다. 다음 세 가지는 스캐너를 무력화합니다.

  • CSS의 background-image로 넣은 히어로 이미지 — CSSOM이 만들어질 때까지 발견되지 않습니다.
  • 자바스크립트로 삽입하는 이미지 — 번들이 실행될 때까지 발견되지 않습니다.
  • 히어로 이미지에 걸린 lazy 로딩 — 가장 흔한 자해입니다. 뷰포트 안의 이미지에 lazy를 걸면 요청 자체가 뒤로 밀립니다.
<!-- 히어로 이미지: 마크업에 두고, 우선순위를 올리고, lazy 를 절대 걸지 않는다 -->
<img
  src="/hero-1200.avif"
  srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"
  sizes="(max-width: 768px) 100vw, 1200px"
  width="1200"
  height="675"
  fetchpriority="high"
  decoding="async"
  alt="여름 시즌 신상품"
/>

<!-- 폴백: 정말로 마크업에 둘 수 없다면 preload 로 스캐너를 대신한다 -->
<link
  rel="preload"
  as="image"
  href="/hero-1200.avif"
  imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w"
  imagesizes="(max-width: 768px) 100vw, 1200px"
  fetchpriority="high"
/>

Next.js의 next/image를 쓴다면 히어로에 priority 속성을 주는 것이 위의 fetchpriority="high"와 lazy 해제를 함께 처리합니다. 목록 하단 이미지에는 반대로 기본 lazy를 유지합니다.

렌더 블로킹 리소스는 줄이되 통째로 없애지는 않는다

흔한 잘못된 조언이 두 개 있습니다.

"모든 CSS를 인라인하라." 크리티컬 CSS를 인라인하는 것은 맞지만, 전체 CSS를 인라인하면 HTML이 커져 TTFB 이후 파싱이 늘어나고 캐시를 전혀 쓸 수 없게 됩니다. 대상은 첫 화면에 필요한 규칙으로 한정합니다.

"자바스크립트에 defer만 붙이면 된다." defer는 실행을 미룰 뿐 다운로드와 파싱 비용은 그대로입니다. LCP 요소가 자바스크립트로 렌더링되는 구조라면 defer는 오히려 LCP를 늦춥니다. 이 경우 필요한 것은 서버 렌더링입니다.

서드파티 스크립트는 별도로 다뤄야 합니다. 태그 매니저, 채팅 위젯, A/B 테스트 도구는 대개 LCP 요소보다 먼저 로드되어 대역폭과 메인 스레드를 가져갑니다.

# 어떤 리소스가 LCP 전에 대역폭을 가져갔는지 본다
npx lighthouse https://shop.example.com/ \
  --only-audits=largest-contentful-paint-element,render-blocking-resources,third-party-summary \
  --output=json --output-path=./lh.json --quiet

node -e "
const r = require('./lh.json');
for (const it of r.audits['third-party-summary'].details.items.slice(0, 5))
  console.log(it.entity.padEnd(28), Math.round(it.blockingTime) + 'ms blocking', Math.round(it.transferSize / 1024) + 'KB');
"
# Google Tag Manager        412ms blocking 148KB
# Intercom                  288ms blocking 231KB
# Hotjar                    134ms blocking  92KB

폰트

폰트는 LCP와 CLS 양쪽에 걸쳐 있습니다. font-display: swap은 텍스트가 안 보이는 구간을 없애 주지만, 폴백 폰트와 실제 폰트의 metrics 차이만큼 레이아웃이 튑니다. 해법은 폴백 폰트의 메트릭을 실제 폰트에 맞추는 것입니다.

@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/pretendard-subset.woff2') format('woff2');
  font-weight: 400 700;
  font-display: swap;
  /* 한글 완성형 + ASCII 만 서브셋으로 — 전체 폰트는 수 MB 가 된다 */
  unicode-range: U+0020-007E, U+AC00-D7A3;
}

/* 폴백 폰트의 메트릭을 실제 폰트에 맞춰 스왑 시점의 이동을 없앤다 */
@font-face {
  font-family: 'Pretendard Fallback';
  src:
    local('Apple SD Gothic Neo'),
    local('Malgun Gothic');
  size-adjust: 103%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}

body {
  font-family: 'Pretendard', 'Pretendard Fallback', sans-serif;
}

첫 화면에 쓰이는 폰트 파일만 preload합니다. 여러 굵기를 전부 preload하면 히어로 이미지와 대역폭을 다투게 됩니다.

INP가 FID를 대체한 이유와 긴 태스크 쪼개기

FID는 첫 상호작용의 입력 지연만 측정했습니다. 즉 브라우저가 이벤트 핸들러를 실행하기 시작할 때까지의 시간입니다. 핸들러가 400ms 동안 메인 스레드를 붙잡고 있어도 FID는 그것을 세지 않았습니다. 그래서 대부분의 사이트가 FID를 쉽게 통과했고, 통과한 사이트도 여전히 버벅였습니다.

INP는 세 구간을 전부 포함하고, 첫 번째가 아니라 페이지 수명 동안의 모든 상호작용을 봅니다.

  • 입력 지연: 메인 스레드가 다른 작업 중이라 핸들러 시작이 밀린 시간
  • 처리 시간: 이벤트 핸들러가 실행된 시간
  • 프레젠테이션 지연: 핸들러가 끝나고 다음 프레임이 그려질 때까지

어느 구간이 문제인지부터 확인합니다.

import { onINP } from 'web-vitals/attribution'

onINP(({ value, attribution: a }) => {
  console.table({
    입력지연: Math.round(a.inputDelay),
    처리시간: Math.round(a.processingDuration),
    프레젠테이션지연: Math.round(a.presentationDelay),
    합계: Math.round(value),
    대상: a.interactionTarget,
    이벤트: a.interactionType,
  })
})
// 입력지연 34 / 처리시간 268 / 프레젠테이션지연 92 / 합계 394
// 대상: button.filter-apply

처리 시간이 크면 핸들러를 쪼갭니다. 여기서 정확히 이해해야 할 것이 하나 있습니다. await는 양보가 아닙니다. await가 만드는 것은 마이크로태스크이고, 마이크로태스크는 현재 태스크가 끝나기 전에 실행되므로 브라우저가 페인트할 틈을 주지 않습니다. 실제로 양보하려면 태스크 경계를 만들어야 합니다.

// 나쁨: 클릭 한 번에 메인 스레드를 380ms 붙잡는다
applyButton.addEventListener('click', () => {
  applyFilters(state) //  12ms
  rerenderTable(rows) // 260ms
  syncToServer(state) // 108ms
})

// 좋음: 눈에 보이는 것만 먼저 그리고, 나머지는 태스크를 나눠 양보한다
applyButton.addEventListener('click', async () => {
  applyFilters(state)
  showPendingState() // 사용자가 즉시 볼 피드백만 동기 실행

  await yieldToMain() // 여기서 브라우저가 페인트한다
  rerenderTable(rows)

  await yieldToMain()
  syncToServer(state) // 화면과 무관한 작업은 맨 뒤로
})

function yieldToMain() {
  // scheduler.yield 는 양보 후 우선순위를 유지해 setTimeout 보다 유리하다
  if ('scheduler' in globalThis && 'yield' in scheduler) return scheduler.yield()
  return new Promise((resolve) => setTimeout(resolve, 0))
}

또 하나 자주 틀리는 지점이 requestIdleCallback입니다. 유휴 시간에 실행되므로 화면에 보여야 하는 업데이트를 여기에 넣으면 프레젠테이션 지연이 그대로 늘어납니다. 유휴 콜백은 분석 이벤트 전송이나 프리페치처럼 사용자가 기다리지 않는 작업에만 씁니다.

프레젠테이션 지연이 큰 경우는 대개 DOM이 너무 큽니다. 화면 밖 영역의 렌더링 비용을 브라우저에 미루게 하거나, 긴 목록을 가상화합니다.

/* 화면 밖 카드의 레이아웃과 페인트를 건너뛴다 */
.product-card {
  content-visibility: auto;
  contain-intrinsic-size: auto 320px; /* 스크롤바 점프를 막는 예상 크기 */
}

긴 태스크는 상시로 감시할 수 있습니다.

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration < 120) continue
    console.warn('long task', Math.round(entry.duration) + 'ms', entry.attribution?.[0]?.name)
  }
}).observe({ type: 'longtask', buffered: true })

CLS의 세 가지 주범과 각각의 정확한 해법

CLS는 원인이 거의 정해져 있어서, 세 가지만 처리하면 대부분 0.1 아래로 내려갑니다.

첫째, 크기가 선언되지 않은 이미지와 iframe. 브라우저가 리소스를 받기 전까지 공간을 얼마나 비워야 할지 모릅니다. 해법은 width와 height 속성을 명시하는 것입니다. 현대 브라우저는 이 두 값에서 aspect-ratio를 자동으로 계산해 반응형에서도 자리를 잡아 줍니다. CSS로 크기를 제어하더라도 속성은 그대로 둡니다.

<!-- 속성은 실제 픽셀 크기, CSS 는 표시 크기 — 둘 다 필요하다 -->
<img src="/thumb.avif" width="640" height="360" style="width: 100%; height: auto" alt="상품" />

둘째, 나중에 삽입되는 배너와 광고와 쿠키 알림. 이미 그려진 콘텐츠 위쪽에 요소를 끼워 넣으면 아래 전체가 밀립니다. 해법은 두 가지입니다. 자리를 미리 확보하거나, 문서 흐름에서 빼는 것입니다.

/* 자리를 미리 잡는다 — 광고가 안 뜨더라도 이동은 없다 */
.ad-slot {
  min-height: 250px;
  contain: layout;
}

/* 쿠키 배너는 흐름에 넣지 말고 오버레이로 띄운다 */
.cookie-banner {
  position: fixed;
  inset-block-end: 0;
  inset-inline: 0;
}

여기서 흔한 오해가 하나 있습니다. "사용자 클릭으로 열리는 아코디언도 CLS에 잡힌다." 잡히지 않습니다. 사용자 입력 후 500ms 이내에 발생한 이동은 hadRecentInput 플래그가 붙어 점수에서 제외됩니다. 다만 그 시간을 넘겨 비동기로 도착한 콘텐츠가 밀어내면 그때는 잡힙니다.

셋째, 폰트 스왑. 앞 절의 size-adjust 방식으로 해결합니다. 텍스트가 많은 페이지에서는 이 하나가 CLS의 절반을 차지하는 경우도 흔합니다.

추가로 하나, 애니메이션은 transform으로 합니다. top, left, width, height를 애니메이션하면 매 프레임이 레이아웃 이동으로 계산되어 점수에 그대로 쌓입니다.

무엇이 움직였는지는 직접 관찰할 수 있습니다.

new PerformanceObserver((list) => {
  for (const shift of list.getEntries()) {
    if (shift.hadRecentInput) continue // 사용자 입력 직후 이동은 제외된다
    for (const src of shift.sources) {
      console.log(shift.value.toFixed(4), src.node, src.previousRect, '→', src.currentRect)
    }
  }
}).observe({ type: 'layout-shift', buffered: true })
// 0.0821 <img class="hero"> DOMRect(0,180,...) → DOMRect(0,412,...)

성능 예산을 CI에 넣기

수동으로 측정해 고친 개선은 두 번의 배포 안에 되돌아옵니다. 회귀를 막는 것은 게이트뿐입니다.

여기서도 흔한 실수가 있습니다. 라이트하우스의 합성 점수에 게이트를 걸지 않습니다. 점수는 여러 지표의 가중 평균이고 가중치는 버전에 따라 바뀝니다. 도구 업데이트만으로 CI가 깨지거나, 반대로 특정 지표가 나빠졌는데 다른 지표가 좋아져 점수가 유지되는 일이 생깁니다. 게이트는 개별 지표의 절대값에 겁니다.

{
  "ci": {
    "collect": {
      "url": ["https://staging.shop.example.com/", "https://staging.shop.example.com/products/12345"],
      "numberOfRuns": 5,
      "settings": { "preset": "desktop" }
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "server-response-time": ["error", { "maxNumericValue": 600 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 180000 }],
        "resource-summary:image:size": ["warn", { "maxNumericValue": 400000 }],
        "unsized-images": "error",
        "uses-responsive-images": "warn"
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}
npx lhci autorun
# ✅  .lighthouseci/ directory writable
# ✅  Configuration file found
# Running Lighthouse 5 time(s) on https://staging.shop.example.com/
# ...
# Checking assertions against 2 URL(s), 5 run(s)
#   ✘  largest-contentful-paint failure  expected <= 2500, found 2884
#      https://staging.shop.example.com/products/12345
# Assertion failed. Exiting with status code 1.

numberOfRuns를 5로 두는 것이 중요합니다. 단일 실행은 편차가 커서 게이트가 무작위로 깨집니다. 라이트하우스 CI는 여러 실행의 중앙값으로 판정합니다.

번들 크기는 별도 예산으로 관리합니다. LCP와 INP 양쪽에 가장 직접적으로 영향을 주는 변수입니다.

npx size-limit
#   Path: .next/static/chunks/pages/index-*.js
#   Size: 164.2 kB with all dependencies, minified and brotlied
#   Size limit: 180 kB
#
#   Path: .next/static/chunks/pages/products/[id]-*.js
#   Size: 221.4 kB with all dependencies, minified and brotlied
#   Size limit: 180 kB
#   ✘ Size limit has been exceeded by 41.4 kB

마지막으로, CI 게이트가 통과했다고 끝이 아닙니다. CI는 회귀만 잡습니다. 배포 후 며칠간 RUM의 75백분위가 실제로 내려갔는지 확인해야 개선이 완료된 것입니다. 실험실에서 400ms를 줄였는데 실사용자 데이터가 꿈쩍하지 않는 경우가 실제로 자주 있고, 그럴 때는 대개 실사용자 환경에만 존재하는 무언가가 병목입니다.

마치며 — 점수가 아니라 75백분위를 고쳐라

기억할 것은 한 문장입니다. 라이트하우스는 회귀를 잡는 도구이고, 고쳐야 하는 대상은 실사용자 데이터의 75백분위입니다.

작업 순서도 여기서 나옵니다. 먼저 RUM을 붙여 경로별, 디바이스별 75백분위를 봅니다. 가장 나쁜 경로를 하나 고르고, LCP라면 네 조각으로 쪼개 어느 구간인지 확정하고, INP라면 입력 지연과 처리 시간과 프레젠테이션 지연 중 어디인지 확정합니다. 그 구간에 해당하는 해법만 적용합니다. 그리고 개별 지표의 절대값으로 CI 게이트를 걸어 되돌아오지 않게 합니다.

가장 흔한 한 방은 여전히 히어로 이미지입니다. lazy 속성을 떼고 fetchpriority="high"를 붙이고 width와 height를 명시하는 것만으로 LCP와 CLS가 동시에 내려가는 페이지가 대단히 많습니다. 거기서 시작하십시오.

더 파고들 자료입니다.

Actually Fixing Core Web Vitals — The Order in Which You Bring LCP, INP, and CLS Down

Introduction — Lighthouse Says 98 While Search Console Stays Red

Run Lighthouse locally and performance scores 98. Deploy, open Search Console a week later, and that same page is still sitting under "URLs needing improvement". The team says "Google data must lag", and a month later the situation is unchanged.

The two numbers measure different things. Lighthouse is the result of one load in a controlled environment; what Search Console shows is a 28-day distribution from real visitors. It is very common for improving the former not to bring the latter along, and knowing where that gap comes from is half the job.

This post identifies that gap and then works through actually bringing the three metrics down, in order of impact.

What LCP, INP, and CLS Each Measure, and Their Thresholds

The three metrics cover different phases of the user experience. LCP is loading, INP is responsiveness, CLS is visual stability.

MetricWhat it measuresGood thresholdMeasurable in the labMost common cause
LCPWhen the largest content element was painted2.5s or lessYesSlow TTFB, a late-discovered hero image
INPFrom interaction to the next paint200ms or lessNo (TBT as a proxy)Long tasks, heavy DOM updates
CLSCumulative score of unexpected layout shift0.1 or lessPartiallyImages without size, late-inserted banners

Two points most articles leave out.

First, these thresholds apply at the 75th percentile, not the average. 75% of page loads must have an LCP of 2.5 seconds or less to count as "good". Even with an average LCP of 2.1 seconds, if 25% exceed 4 seconds you fail. The percentile discussion repeats itself exactly here.

Second, mobile and desktop are evaluated separately. It is common to look only at desktop and conclude that you passed, but if most of your traffic is mobile, the number to improve is the mobile one.

The CLS calculation is also worth one pass. CLS is not a simple sum over the whole page lifetime. Segments where shifts occurred are grouped into session windows of at most 5 seconds with gaps of no more than 1 second between shifts, and the value of the window with the largest sum is used. That is why an infinite scroll page left open for a long time does not grow an unbounded score.

Lab Data and Field Data — Which One Is the Truth

Lighthouse simulates a device and a network and loads once. The cache is empty, third-party scripts are at the mercy of that day's response times, and the user clicks nothing. That is why Lighthouse cannot measure INP. There is no interaction. It shows Total Blocking Time (TBT) as a proxy instead, which correlates but is not the same value.

Field data is the opposite. Old Android devices, 3G stretches, repeat visits with a warm cache, ad blockers, and real clicks are all mixed in. This is what is used for search evaluation and this is what users actually experience.

The conclusion is simple. Lab data is for regression detection, and the basis for judgement is field data. Target the Lighthouse score and you end up doing optimizations that raise the score and leave the user experience untouched.

Collecting your own RUM can start with one snippet.

// web-vitals v4 — the attribution build also tells you "what" was slow
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution'

function send(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: Math.round(metric.value),
    rating: metric.rating, // good | needs-improvement | poor
    nav_type: metric.navigationType,
    path: location.pathname,
    // merge devices together and you can diagnose nothing
    device: matchMedia('(max-width: 768px)').matches ? 'mobile' : 'desktop',
    conn: navigator.connection?.effectiveType,
    attribution: metric.attribution,
  })
  navigator.sendBeacon('/rum/vitals', body)
}

onLCP(send)
onINP(send)
onCLS(send)
onTTFB(send)

Once collected, always look at the 75th percentile per path and per device. A single overall average tells you nothing about which page to fix.

The Order for Bringing LCP Down — From Server Response to Fonts

Look at LCP as a whole and there is nowhere to start. Split it into four pieces and the answer appears.

  1. TTFB — until the server sends the first byte
  2. Resource load delay — from TTFB until the LCP resource request starts
  3. Resource load duration — until that resource has fully arrived
  4. Render delay — from fully arrived until actually painted on screen
import { onLCP } from 'web-vitals/attribution'

onLCP(({ value, attribution: a }) => {
  console.table({
    TTFB: Math.round(a.timeToFirstByte),
    'Resource load delay': Math.round(a.resourceLoadDelay),
    'Resource load time': Math.round(a.resourceLoadDuration),
    'Render delay': Math.round(a.elementRenderDelay),
    Total: Math.round(value),
    Element: a.target,
  })
})
// ┌──────────────────────┬───────┐
// │ TTFB                 │   412 │
// │ Resource load delay  │  1180 │  <-- the culprit
// │ Resource load time   │   340 │
// │ Render delay         │    96 │
// │ Total                │  2028 │
// └──────────────────────┴───────┘

The target split is roughly 40 percent each for TTFB and resource load duration, with the two delay items close to zero. In the example above, a load delay of 1,180ms means the browser did not know the hero image existed for over a second. No amount of optimizing the image file reduces that number.

Check TTFB First

curl -s -o /dev/null \
  -w 'dns:%{time_namelookup}s  tcp:%{time_connect}s  tls:%{time_appconnect}s  ttfb:%{time_starttransfer}s  total:%{time_total}s\n' \
  https://shop.example.com/products/12345
# dns:0.021s  tcp:0.043s  tls:0.118s  ttfb:0.412s  total:0.588s

# Check cache hit status at the same time — a CDN miss is a common cause of slow TTFB
curl -sI https://shop.example.com/products/12345 | grep -iE 'cache|age|server-timing'
# cache-control: public, max-age=0, s-maxage=300
# age: 0
# x-cache: MISS
# server-timing: db;dur=284, render;dur=96

Emit the Server-Timing header from the server and you can see the internal composition of TTFB in your RUM data. Without it there is no way to tell whether a slow TTFB is the network or the backend.

Resource Load Delay Is the Biggest Lever

If the LCP element is an image, the preload scanner has to be able to find it. The following three defeat the scanner.

  • A hero image set via a CSS background-image — not discovered until the CSSOM is built.
  • An image inserted by JavaScript — not discovered until the bundle executes.
  • Lazy loading applied to the hero image — the most common self-inflicted wound. Put lazy on an image inside the viewport and the request itself gets pushed back.
<!-- Hero image: keep it in the markup, raise its priority, and never apply lazy -->
<img
  src="/hero-1200.avif"
  srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"
  sizes="(max-width: 768px) 100vw, 1200px"
  width="1200"
  height="675"
  fetchpriority="high"
  decoding="async"
  alt="New summer season arrivals"
/>

<!-- Fallback: if it truly cannot live in the markup, stand in for the scanner with preload -->
<link
  rel="preload"
  as="image"
  href="/hero-1200.avif"
  imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w"
  imagesizes="(max-width: 768px) 100vw, 1200px"
  fetchpriority="high"
/>

If you use next/image in Next.js, giving the hero the priority attribute handles both the fetchpriority="high" above and turning lazy off. For images further down a list, keep the default lazy behaviour instead.

Reduce Render-Blocking Resources Without Eliminating Them Wholesale

There are two pieces of common bad advice.

"Inline all your CSS." Inlining critical CSS is right, but inlining the entire stylesheet grows the HTML, increases parsing after TTFB, and makes caching impossible. Limit the target to the rules needed for the first screen.

"Just add defer to your JavaScript." defer only postpones execution; the download and parse costs remain. If the LCP element is rendered by JavaScript, defer actually delays LCP. What that case needs is server rendering.

Third-party scripts have to be handled separately. Tag managers, chat widgets, and A/B testing tools generally load before the LCP element and take bandwidth and main thread away from it.

# See which resources took bandwidth before LCP
npx lighthouse https://shop.example.com/ \
  --only-audits=largest-contentful-paint-element,render-blocking-resources,third-party-summary \
  --output=json --output-path=./lh.json --quiet

node -e "
const r = require('./lh.json');
for (const it of r.audits['third-party-summary'].details.items.slice(0, 5))
  console.log(it.entity.padEnd(28), Math.round(it.blockingTime) + 'ms blocking', Math.round(it.transferSize / 1024) + 'KB');
"
# Google Tag Manager        412ms blocking 148KB
# Intercom                  288ms blocking 231KB
# Hotjar                    134ms blocking  92KB

Fonts

Fonts straddle both LCP and CLS. font-display: swap removes the interval where text is invisible, but the layout jumps by the difference in metrics between the fallback font and the real font. The fix is to match the fallback font's metrics to the real font.

@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/pretendard-subset.woff2') format('woff2');
  font-weight: 400 700;
  font-display: swap;
  /* subset to precomposed Hangul + ASCII only — the full font runs to several MB */
  unicode-range: U+0020-007E, U+AC00-D7A3;
}

/* Match the fallback font metrics to the real font to remove the shift at swap time */
@font-face {
  font-family: 'Pretendard Fallback';
  src:
    local('Apple SD Gothic Neo'),
    local('Malgun Gothic');
  size-adjust: 103%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}

body {
  font-family: 'Pretendard', 'Pretendard Fallback', sans-serif;
}

Preload only the font files used on the first screen. Preload every weight and they end up fighting the hero image for bandwidth.

Why INP Replaced FID, and Breaking Up Long Tasks

FID measured only the input delay of the first interaction. That is, the time until the browser began running the event handler. Even if the handler then held the main thread for 400ms, FID did not count it. So most sites passed FID easily, and the sites that passed still stuttered.

INP includes all three segments, and it looks at every interaction over the page lifetime rather than only the first.

  • Input delay: time the handler start was pushed back because the main thread was busy
  • Processing duration: time the event handler ran
  • Presentation delay: from the handler finishing until the next frame is painted

Start by identifying which segment is the problem.

import { onINP } from 'web-vitals/attribution'

onINP(({ value, attribution: a }) => {
  console.table({
    'input delay': Math.round(a.inputDelay),
    'processing duration': Math.round(a.processingDuration),
    'presentation delay': Math.round(a.presentationDelay),
    total: Math.round(value),
    target: a.interactionTarget,
    event: a.interactionType,
  })
})
// input delay 34 / processing duration 268 / presentation delay 92 / total 394
// target: button.filter-apply

If processing duration is large, break the handler up. There is one thing to understand precisely here. await is not yielding. What await creates is a microtask, and microtasks run before the current task finishes, which gives the browser no opening to paint. To actually yield you have to create a task boundary.

// Bad: one click holds the main thread for 380ms
applyButton.addEventListener('click', () => {
  applyFilters(state) //  12ms
  rerenderTable(rows) // 260ms
  syncToServer(state) // 108ms
})

// Good: paint what is visible first, split the rest into tasks and yield
applyButton.addEventListener('click', async () => {
  applyFilters(state)
  showPendingState() // run only the feedback the user sees immediately, synchronously

  await yieldToMain() // the browser paints here
  rerenderTable(rows)

  await yieldToMain()
  syncToServer(state) // work unrelated to the screen goes last
})

function yieldToMain() {
  // scheduler.yield keeps priority after yielding, which beats setTimeout
  if ('scheduler' in globalThis && 'yield' in scheduler) return scheduler.yield()
  return new Promise((resolve) => setTimeout(resolve, 0))
}

Another frequently mistaken point is requestIdleCallback. It runs during idle time, so putting an update that has to appear on screen in there increases presentation delay directly. Use idle callbacks only for work the user is not waiting on, such as sending analytics events or prefetching.

When presentation delay is large, the DOM is usually too big. Have the browser defer the rendering cost of offscreen areas, or virtualize long lists.

/* Skip layout and paint for offscreen cards */
.product-card {
  content-visibility: auto;
  contain-intrinsic-size: auto 320px; /* estimated size that stops the scrollbar jumping */
}

Long tasks can be monitored continuously.

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration < 120) continue
    console.warn('long task', Math.round(entry.duration) + 'ms', entry.attribution?.[0]?.name)
  }
}).observe({ type: 'longtask', buffered: true })

The Three Usual Suspects Behind CLS and the Exact Fix for Each

The causes of CLS are almost fixed, so handling three things usually brings it below 0.1.

First, images and iframes with no declared size. The browser does not know how much space to leave until the resource arrives. The fix is to specify the width and height attributes. Modern browsers compute aspect-ratio automatically from those two values and hold the space even in responsive layouts. Keep the attributes even when you control the size with CSS.

<!-- The attributes are the real pixel size, the CSS is the display size — you need both -->
<img src="/thumb.avif" width="640" height="360" style="width: 100%; height: auto" alt="Product" />

Second, banners, ads, and cookie notices inserted later. Slot an element above already-painted content and everything below it gets pushed down. There are two fixes. Reserve the space in advance, or take it out of the document flow.

/* Reserve the space in advance — no shift even if the ad never arrives */
.ad-slot {
  min-height: 250px;
  contain: layout;
}

/* Do not put the cookie banner in the flow, float it as an overlay */
.cookie-banner {
  position: fixed;
  inset-block-end: 0;
  inset-inline: 0;
}

There is a common misunderstanding here. "An accordion opened by a user click also counts toward CLS." It does not. Shifts occurring within 500ms of user input get the hadRecentInput flag and are excluded from the score. If content arriving asynchronously past that window pushes things around, however, that does count.

Third, font swap. Solve it with the size-adjust approach from the previous section. On text-heavy pages it is common for this alone to account for half the CLS.

One more thing: animate with transform. Animate top, left, width, or height and every frame is counted as a layout shift and piles straight onto the score.

You can observe what moved directly.

new PerformanceObserver((list) => {
  for (const shift of list.getEntries()) {
    if (shift.hadRecentInput) continue // shifts right after user input are excluded
    for (const src of shift.sources) {
      console.log(shift.value.toFixed(4), src.node, src.previousRect, '→', src.currentRect)
    }
  }
}).observe({ type: 'layout-shift', buffered: true })
// 0.0821 <img class="hero"> DOMRect(0,180,...) → DOMRect(0,412,...)

Putting Performance Budgets in CI

Improvements measured and fixed by hand come back within two deployments. The only thing that stops regressions is a gate.

There is a common mistake here too. Do not gate on the Lighthouse composite score. The score is a weighted average of several metrics and the weights change between versions. A tool update alone can break CI, or conversely a metric can get worse while another improves and the score holds. Gate on the absolute value of individual metrics.

{
  "ci": {
    "collect": {
      "url": ["https://staging.shop.example.com/", "https://staging.shop.example.com/products/12345"],
      "numberOfRuns": 5,
      "settings": { "preset": "desktop" }
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "server-response-time": ["error", { "maxNumericValue": 600 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 180000 }],
        "resource-summary:image:size": ["warn", { "maxNumericValue": 400000 }],
        "unsized-images": "error",
        "uses-responsive-images": "warn"
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}
npx lhci autorun
# ✅  .lighthouseci/ directory writable
# ✅  Configuration file found
# Running Lighthouse 5 time(s) on https://staging.shop.example.com/
# ...
# Checking assertions against 2 URL(s), 5 run(s)
#   ✘  largest-contentful-paint failure  expected <= 2500, found 2884
#      https://staging.shop.example.com/products/12345
# Assertion failed. Exiting with status code 1.

Setting numberOfRuns to 5 matters. A single run has too much variance and the gate breaks at random. Lighthouse CI judges on the median of the runs.

Manage bundle size as a separate budget. It is the variable that most directly affects both LCP and INP.

npx size-limit
#   Path: .next/static/chunks/pages/index-*.js
#   Size: 164.2 kB with all dependencies, minified and brotlied
#   Size limit: 180 kB
#
#   Path: .next/static/chunks/pages/products/[id]-*.js
#   Size: 221.4 kB with all dependencies, minified and brotlied
#   Size limit: 180 kB
#   ✘ Size limit has been exceeded by 41.4 kB

Finally, passing the CI gate is not the end. CI only catches regressions. The improvement is complete only when you confirm, over the days after deployment, that the RUM 75th percentile actually came down. It genuinely happens often that you shave 400ms in the lab and field data does not budge, and in those cases the bottleneck is usually something that exists only in real user environments.

Closing — Fix the 75th Percentile, Not the Score

There is one sentence to remember. Lighthouse is the tool that catches regressions, and the thing you have to fix is the 75th percentile of field data.

The order of work follows from that. First attach RUM and look at the 75th percentile per path and per device. Pick the single worst path, and if it is LCP, split it into four pieces and pin down which segment it is; if it is INP, pin down whether it is input delay, processing duration, or presentation delay. Apply only the fix that belongs to that segment. Then gate CI on the absolute value of individual metrics so it does not come back.

The most common single win is still the hero image. There are a great many pages where simply removing the lazy attribute, adding fetchpriority="high", and specifying width and height brings LCP and CLS down at the same time. Start there.

Further reading.