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:
| Metric | What it measures | Good 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:
- Landing page and product pages: directly affect conversion
- Checkout flow: CLS and INP here have direct revenue impact
- 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
| Symptom | Most Likely Cause | Investigation |
|---|---|---|
| Lab LCP good, field LCP bad | Slow device/network, uncached load, ads loading | Profile on a mid-tier Android device with network throttling |
| LCP element changes | Carousel, random hero images | Stabilize the largest element or exclude carousel from LCP candidates |
| CLS only on mobile | Different layout, smaller fonts causing different reflow | Test on physical device, check mobile-specific CSS |
| Intermittent CLS | Font swap, delayed ad loading | Add font-display: swap + size-adjust; reserve ad slot dimensions |
| INP bad on one widget | Expensive click handler | Profile that specific interaction in DevTools Performance |
| Third-party scripts dominate | Tag manager, chat widget | Audit with Coverage panel, defer non-critical scripts |
Key Takeaways
- LCP: find the LCP element in Lighthouse, add
fetchpriority="high", serve in WebP/AVIF, providewidth/height, reduce TTFB with CDN and caching - CLS: always specify image dimensions, use
aspect-ratio, tunefont-display: swapwithsize-adjust, reserve space for ads/banners withmin-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와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- Next.js App Router에서 SSR·SSG·ISR 선택 가이드 | 렌더링 전략과 캐싱
- Tailwind CSS로 컴포넌트·토큰 정리하는 실전 패턴 | 디자인 시스템
- Node.js 성능 최적화 | 클러스터링, 캐싱, 프로파일링
이 글에서 다루는 키워드 (관련 검색어)
Web Performance, Core Web Vitals, LCP, CLS, INP, SEO, Optimization 등으로 검색하시면 이 글이 도움이 됩니다.