본문으로 건너뛰기
Previous
Next
Core Web Vitals Optimization Checklist | LCP

Core Web Vitals Optimization Checklist | LCP

Core Web Vitals Optimization Checklist | LCP

이 글의 핵심

Improve LCP, CLS, and INP with concrete techniques: image sizing, fetchpriority, font-display, layout reservation, long-task splitting, and third-party deferral. Measure with field data and Lighthouse.

What Are Core Web Vitals?

Core Web Vitals are a set of metrics Google uses to measure real user experience. They became a Search ranking signal in 2021 and are now part of the Page Experience ranking update. More importantly, they measure things users actually notice: how fast the main content loads, whether the page jumps around during load, and how quickly it responds to clicks and taps.

The three current metrics:

MetricWhat it measuresGood threshold
LCP (Largest Contentful Paint)When the largest visible content element paints≤ 2.5s
CLS (Cumulative Layout Shift)Total unexpected layout shift during load≤ 0.1
INP (Interaction to Next Paint)Delay from user interaction to next visual update≤ 200ms

Note: thresholds are guidelines from Google’s research. Your actual targets depend on your user base, device mix, and network conditions. Segment by country and device type before setting targets.


Where to Measure

Field Data (Real Users)

  • PageSpeed Insights: shows CrUX (Chrome User Experience Report) data for your URL — real user percentiles
  • Google Search Console: Core Web Vitals report by page group
  • Web Vitals JavaScript library: collect real user data in your analytics
// Install: npm install web-vitals
import { onLCP, onCLS, onINP } from 'web-vitals';

function sendToAnalytics(metric) {
    fetch('/analytics', {
        method: 'POST',
        body: JSON.stringify({
            name: metric.name,
            value: metric.value,
            rating: metric.rating,   // 'good', 'needs-improvement', 'poor'
        }),
    });
}

onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);

Lab Data (Controlled Environment)

  • Lighthouse (Chrome DevTools, PageSpeed Insights): consistent lab conditions, good for before/after comparison
  • WebPageTest: more realistic network simulation, waterfall charts
  • CI integration: run Lighthouse in GitHub Actions to catch regressions
# .github/workflows/lighthouse.yml
- name: Run Lighthouse CI
  uses: treosh/lighthouse-ci-action@v12
  with:
    urls: |
      https://your-site.com/
      https://your-site.com/product/
    budgetPath: './budget.json'
    uploadArtifacts: true

LCP: Largest Contentful Paint

LCP measures when the largest image, text block, or video in the viewport finishes rendering. Common LCP elements: hero images, above-the-fold <h1> headings, and banner images.

Step 1: Identify the LCP Element

Open Chrome DevTools → Performance → record a page load → look for the “LCP” marker in the timeline. Or use Lighthouse → LCP section which shows the element.

Common LCP elements by site type:

  • Marketing: hero <img> (most common)
  • Blog: first <img> or <h1>
  • E-commerce: product image above the fold

Step 2: Eliminate Discovery Delays

The LCP element must be discoverable from the initial HTML — not injected by JavaScript:

<!-- GOOD: browser discovers this immediately in the HTML -->
<img src="/hero.webp" alt="Hero image" width="1200" height="630"
     fetchpriority="high" decoding="async">

<!-- BAD: hero image injected by JS — browser discovers it late -->
<div id="hero-container"></div>
<script>
  document.getElementById('hero-container').innerHTML =
    '<img src="/hero.webp" alt="Hero">';
</script>

Step 3: Set fetch Priority

<!-- fetchpriority="high" tells the browser to fetch this before other images -->
<!-- Only use on the actual LCP element — one per page -->
<img src="/hero.webp"
     alt="Product launch"
     width="1200" height="630"
     fetchpriority="high"
     decoding="async"
     loading="eager">

<!-- For below-the-fold images: lower priority, lazy load -->
<img src="/product-detail.webp"
     alt="Product detail"
     width="800" height="600"
     fetchpriority="low"
     loading="lazy">

Step 4: Optimize Image Format and Size

<!-- Use modern formats with srcset for responsive images -->
<picture>
  <source
    srcset="/hero-480.avif 480w, /hero-960.avif 960w, /hero-1440.avif 1440w"
    sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
    type="image/avif">
  <source
    srcset="/hero-480.webp 480w, /hero-960.webp 960w, /hero-1440.webp 1440w"
    sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
    type="image/webp">
  <img src="/hero-1440.jpg" alt="Hero" width="1440" height="810"
       fetchpriority="high">
</picture>

Step 5: Fix Server Response Time (TTFB)

LCP = TTFB + resource load time. A slow server caps LCP regardless of image optimization:

# Nginx: enable gzip and caching headers
gzip on;
gzip_types text/html text/css application/javascript image/webp;

location ~* \.(webp|avif|jpg|png|css|js)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

Use a CDN (Cloudflare, CloudFront, Vercel Edge) to serve assets from a location close to the user.


CLS: Cumulative Layout Shift

CLS measures unexpected layout shifts — elements moving as the page loads. This happens when content loads asynchronously and pushes other content around.

Always Reserve Space for Images

<!-- Without dimensions: browser doesn't know the size until the image loads -->
<!-- Other content shifts down when the image appears -->
<img src="/product.webp" alt="Product">  <!-- BAD: no dimensions -->

<!-- With dimensions: browser reserves space immediately -->
<img src="/product.webp" alt="Product" width="800" height="600">  <!-- GOOD -->
/* CSS alternative: use aspect-ratio to reserve space */
.hero-image {
    aspect-ratio: 16 / 9;
    width: 100%;
    object-fit: cover;
}

Fix Font Loading Shifts

Web fonts are a major source of CLS. When the fallback font swaps to the custom font, text reflowing causes layout shift:

/* font-display: swap shows fallback font immediately, then swaps */
/* Combine with size-adjust to minimize the shift */
@font-face {
    font-family: 'Roboto';
    src: url('/fonts/roboto.woff2') format('woff2');
    font-display: swap;
}

/* size-adjust: scale the fallback font to match the custom font's metrics */
/* Reduces the visual jump when the swap happens */
@font-face {
    font-family: 'Roboto Fallback';
    src: local('Arial');
    size-adjust: 100.06%;
    ascent-override: 92.7%;
    descent-override: 24.4%;
    line-gap-override: 0%;
}

Preloading fonts reduces the swap window:

<link rel="preload" href="/fonts/roboto.woff2"
      as="font" type="font/woff2" crossorigin="anonymous">

Reserve Space for Ads and Dynamic Content

/* Reserve height for an ad slot so surrounding content doesn't shift */
.ad-container {
    min-height: 250px;  /* standard ad height */
    display: flex;
    align-items: center;
    justify-content: center;
}

/* Cookie banner: use fixed positioning to avoid pushing content */
.cookie-banner {
    position: fixed;
    bottom: 0;
    width: 100%;
    /* Does NOT push page content — no layout shift */
}

Use transform for Animations

Properties that trigger layout recalculation cause CLS:

/* BAD: animating top/left/margin causes layout recalculation */
.card:hover {
    margin-top: -10px;   /* shifts surrounding elements */
}

/* GOOD: transform does not affect layout — zero CLS impact */
.card:hover {
    transform: translateY(-10px);
}

INP: Interaction to Next Paint

INP measures the delay from a user interaction (click, tap, key press) to the next visual update. Long-running JavaScript on the main thread is the primary cause.

Identify Long Tasks

Long tasks (>50ms on the main thread) delay the browser’s ability to paint after an interaction:

// Observe long tasks with PerformanceObserver
const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
        console.log('Long task:', entry.duration.toFixed(1), 'ms',
                    'at:', entry.startTime.toFixed(1));
    }
});
observer.observe({ type: 'longtask', buffered: true });

In Chrome DevTools: Performance → record interaction → look for red “Long task” markers in the main thread.

Split Long Work with Scheduler

// Instead of blocking the main thread:
function processAllItems(items) {
    for (const item of items) {
        heavyProcessing(item);   // blocks for 500ms if items.length = 1000
    }
}

// Yield to the browser between chunks:
async function processInChunks(items, chunkSize = 50) {
    for (let i = 0; i < items.length; i += chunkSize) {
        const chunk = items.slice(i, i + chunkSize);
        for (const item of chunk) {
            heavyProcessing(item);
        }
        // Yield: browser can paint and handle other events
        await new Promise(resolve => setTimeout(resolve, 0));
    }
}

// Even better: use scheduler.yield() if available (Chrome 115+)
async function processWithYield(items) {
    for (const item of items) {
        heavyProcessing(item);
        if ('scheduler' in window) {
            await scheduler.yield();
        }
    }
}

Defer Non-Critical Work in Event Handlers

// Don't do everything synchronously on click
button.addEventListener('click', (event) => {
    // Immediate: minimal work needed for the next paint
    updateButtonState(button, 'loading');

    // Deferred: analytics, logging, heavy work after paint
    requestAnimationFrame(() => {
        requestIdleCallback(() => {
            sendAnalytics('button_click');
            prefetchNextPage();
        });
    });
});

Move Heavy Work Off the Main Thread

// web-worker.js
self.addEventListener('message', (e) => {
    const { data, operation } = e.data;
    let result;

    if (operation === 'sort') {
        result = data.slice().sort((a, b) => a - b);
    } else if (operation === 'filter') {
        result = data.filter(item => item.score > 0.5);
    }

    self.postMessage({ result });
});

// main.js
const worker = new Worker('/web-worker.js');

function heavySortInWorker(data) {
    return new Promise((resolve) => {
        worker.onmessage = (e) => resolve(e.data.result);
        worker.postMessage({ data, operation: 'sort' });
    });
}

button.addEventListener('click', async () => {
    const sorted = await heavySortInWorker(largeDataset);  // main thread free during sort
    renderTable(sorted);
});

Audit Third-Party Scripts

Third-party scripts (analytics, chat widgets, tag managers) often dominate INP:

// Defer non-critical third parties until after the page is interactive
window.addEventListener('load', () => {
    // Inject chat widget after load
    setTimeout(() => {
        const script = document.createElement('script');
        script.src = 'https://chat-widget.example.com/widget.js';
        script.async = true;
        document.body.appendChild(script);
    }, 3000);   // 3 second delay
});

Or use a facade: show a static preview (e.g., YouTube thumbnail) that loads the real embed only on click.


Prioritization: Where to Start

Not all pages matter equally. Focus efforts where users actually go:

  1. Landing page and product pages: directly affect conversion
  2. Checkout flow: CLS and INP here have direct revenue impact
  3. Search results page: LCP matters for first impression after a Google click

Ignore:

  • Admin dashboards (no organic traffic)
  • Deep settings pages (low traffic)
  • Pages not in Google’s crawl budget

Troubleshooting Reference

SymptomMost Likely CauseInvestigation
Lab LCP good, field LCP badSlow device/network, uncached load, ads loadingProfile on a mid-tier Android device with network throttling
LCP element changesCarousel, random hero imagesStabilize the largest element or exclude carousel from LCP candidates
CLS only on mobileDifferent layout, smaller fonts causing different reflowTest on physical device, check mobile-specific CSS
Intermittent CLSFont swap, delayed ad loadingAdd font-display: swap + size-adjust; reserve ad slot dimensions
INP bad on one widgetExpensive click handlerProfile that specific interaction in DevTools Performance
Third-party scripts dominateTag manager, chat widgetAudit with Coverage panel, defer non-critical scripts

Key Takeaways

  • LCP: find the LCP element in Lighthouse, add fetchpriority="high", serve in WebP/AVIF, provide width/height, reduce TTFB with CDN and caching
  • CLS: always specify image dimensions, use aspect-ratio, tune font-display: swap with size-adjust, reserve space for ads/banners with min-height
  • INP: identify long tasks (>50ms) in DevTools, yield between chunks with scheduler.yield(), move heavy computation to Web Workers, defer third-party scripts
  • Measure field data first: Lighthouse lab scores differ from real user experience — segment by device and country
  • Prioritize high-traffic pages: landing pages, product pages, and checkout — not admin panels or settings
  • INP replaces FID: if you see FID in old reports, the current metric is INP — update your dashboards

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Improve LCP with resource priority and images, stabilize CLS with dimensions and fonts, and reduce INP by shrinking main… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Web Performance, Core Web Vitals, LCP, CLS, INP, SEO, Optimization 등으로 검색하시면 이 글이 도움이 됩니다.