Split View: 현대 웹 성능의 과학 심화 가이드 — Core Web Vitals, INP, LCP, CLS, RUM, Lighthouse, Critical Rendering Path, Speculation Rules까지 (2025)
현대 웹 성능의 과학 심화 가이드 — Core Web Vitals, INP, LCP, CLS, RUM, Lighthouse, Critical Rendering Path, Speculation Rules까지 (2025)
TL;DR — 2024년은 웹 성능의 지각변동이었다. **INP(Interaction to Next Paint)**가 FID를 대체하며 Core Web Vitals 3대 지표가 LCP · CLS · INP로 재편됐고, Speculation Rules API(Chrome 121), Partial Prerendering(Next.js 14), HTTP/3 QUIC(전체 트래픽의 30% 돌파), Early Hints(103 Status)가 같은 해에 표준화·일반화됐다. 이 글은 '왜 내 사이트가 느린가'에 답하기 위해 Critical Rendering Path 원리부터 Long Task 쪼개기, RUM vs Lab Data 차이, Lighthouse가 자주 틀리는 이유, Islands · Resumability(Qwik), Image/Font 최적화(AVIF, font-display: optional), 2025년 성능 도구 스택(Vercel Analytics, SpeedCurve, PerfEtto)까지 — 현대 웹 성능의 전 지형도를 정리한다.
왜 웹 성능이 다시 '뜨거운 주제'가 됐는가
웹 성능은 2010년대 초 YSlow(Yahoo)와 PageSpeed Insights(Google)가 대중화한 뒤 한동안 '인프라 팀의 체크리스트' 수준에 머물렀다. 그런데 2020년 Google이 Core Web Vitals를 검색 랭킹 신호로 공식 발표하고, 2024년 INP가 FID를 대체하며 — 성능은 검색 순위 · 광고 전환율 · 사용자 이탈률을 직접 좌우하는 비즈니스 지표가 됐다.
숫자로 보면 이렇다:
- Amazon: 페이지 로드 100ms 지연당 매출 1% 감소 (2006년 기준, 2024년엔 더 민감)
- Walmart: LCP 1초 단축 → 전환율 2% 향상
- BBC: 1초 지연당 10% 이탈 증가
- Vodafone: LCP 31% 개선 → 판매 전환 8% 증가 (2021 케이스스터디)
2024년 Chrome UX Report(CrUX, 실제 사용자 데이터)에 따르면, 전 세계 상위 100만 사이트 중 Core Web Vitals 3개를 모두 통과한 사이트는 **42%**뿐이다. 반면 React/Vue/Angular 같은 SPA 프레임워크를 쓰는 사이트의 통과율은 **28%**로 정적 사이트(65%)보다 크게 낮다. 성능은 '프레임워크 선택의 대가'이기도 하다.
이 글의 목적은 Core Web Vitals 3개 지표의 정의와 측정법, 그리고 왜 같은 코드가 Lab에선 빠르고 Field에선 느린지, 어떻게 Long Task와 Layout Shift를 추적·수정하는지를 원리부터 실무까지 한 호흡에 정리하는 것이다. 성능은 '기법 하나'가 아니라 '렌더링 파이프라인 전체의 이해'에서 나온다.
브라우저 렌더링 파이프라인 — 픽셀까지 도달하는 여정
웹 성능을 논하려면 브라우저가 HTML을 받아 픽셀을 그리기까지 거치는 Critical Rendering Path를 알아야 한다. 이 경로에서 시간이 새는 모든 지점이 '성능 문제'의 원인이다.
1. Navigation — URL 입력 / 링크 클릭
↓
2. DNS Lookup — example.com → 93.184.216.34
↓
3. TCP + TLS Handshake — 3-way + SSL (HTTP/1.1: ~300ms, HTTP/3: ~100ms)
↓
4. HTTP Request — GET /
↓
5. TTFB — Time To First Byte (서버 응답)
↓
6. HTML Parsing — DOM 트리 구축 (파싱 중 외부 리소스 요청)
↓
7. CSSOM Construction — CSS 파싱, CSSOM 트리
↓
8. Render Tree — DOM + CSSOM → 화면에 그릴 노드만
↓
9. Layout (Reflow) — 각 노드의 위치/크기 계산
↓
10. Paint — 픽셀 정보 생성 (레이어 단위)
↓
11. Composite — GPU에서 레이어 합성
↓
12. 화면 표시 — 사용자가 보는 최초 픽셀 (FCP)
각 단계에서 일어나는 핵심 병목:
- DNS + TCP + TLS — 첫 바이트를 받기까지 RTT(Round Trip Time) 3-4회. 이걸 HTTP/3 QUIC(0-RTT resumption)이 공격한다.
- TTFB — 서버 응답 시간. SSR이면 DB + 렌더링, 정적 파일이면 CDN 캐시 히트 여부가 결정.
- HTML Parsing Blocking —
<script>태그는 기본적으로 파싱을 멈춘다.async/defer로 해소. - CSSOM Blocking — CSS는 렌더링 블로킹 리소스. CSS 로딩이 끝날 때까지 Render Tree 안 만들어짐.
- Layout —
offsetHeight읽기 같은 동기 강제 레이아웃(Forced Reflow)이 성능 킬러. - Paint / Composite —
will-change: transform,contain: layout으로 GPU 레이어 분리 유도.
React/Vue 같은 JS 프레임워크를 쓰면 여기에 JS 다운로드 · 파싱 · 실행 · Hydration이 추가된다. 이 추가 단계가 SPA가 Core Web Vitals에서 불리한 근본 이유다.
Core Web Vitals 3대 지표 (2024-2025)
Google은 2020년 Core Web Vitals를 사용자 경험의 3대 축으로 정의했다:
- 로딩 (Loading) — LCP
- 상호작용성 (Interactivity) — FID → INP (2024.03 교체)
- 시각적 안정성 (Visual Stability) — CLS
LCP — Largest Contentful Paint (로딩)
정의: 뷰포트에 보이는 가장 큰 콘텐츠 요소(이미지, 비디오 포스터, 블록 텍스트)가 화면에 그려지는 시점.
기준: 2.5초 이하 = Good, 2.5-4.0초 = Needs Improvement, 4.0초 초과 = Poor.
측정: LargestContentfulPaint PerformanceObserver API.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP element:', entry.element)
console.log('LCP time:', entry.startTime)
console.log('LCP render time:', entry.renderTime)
console.log('LCP size:', entry.size)
}
}).observe({ type: 'largest-contentful-paint', buffered: true })
LCP가 느려지는 주범 4가지:
- 느린 TTFB — 서버 응답이 1초 걸리면 LCP 2.5초 통과는 불가능.
- 렌더링 블로킹 리소스 —
<link rel="stylesheet">지연이 LCP를 그대로 지연시킴. - 리소스 로드 시간 — LCP 이미지가 lazy-load되면 안 됨.
fetchpriority="high"사용. - 클라이언트 사이드 렌더링 — React가 JS 다운받아 Hydration 끝나야 LCP 요소가 DOM에 올라옴.
LCP 최적화 체크리스트:
- LCP 이미지에
fetchpriority="high"+loading="eager"+decoding="async" <link rel="preload" as="image" href="/hero.webp" imagesrcset="..." fetchpriority="high">- Above-the-fold 콘텐츠는 인라인 CSS로
- 폰트는
font-display: optional또는swap - CDN + HTTP/3 + Brotli 압축
- SSR 또는 SSG (CSR 피하기)
CLS — Cumulative Layout Shift (시각적 안정성)
정의: 페이지 로드 중 예기치 않게 레이아웃이 이동한 정도. 이동한 요소의 '영향 비율' × '이동 거리'를 누적.
기준: 0.1 이하 = Good, 0.1-0.25 = Needs Improvement, 0.25 초과 = Poor.
CLS 공식: impact fraction × distance fraction
- Impact Fraction: 뷰포트 대비 이동한 요소가 차지하는 비율
- Distance Fraction: 이동한 거리 / 뷰포트 크기
CLS 주범 5가지:
- 크기 지정 없는 이미지 —
<img>에 width/height 누락 → 이미지 로드 후 레이아웃 밀림. - 크기 지정 없는 광고/임베드 — AdSense, YouTube embed, iframe.
- FOIT/FOUT — 폰트 로드 전후 글꼴 변경으로 텍스트 높이 변화.
- 동적 콘텐츠 주입 — 상단에 배너/알림 삽입.
- Web Font 로드 지연 —
font-display: swap이 CLS를 일으킴 (아이러니).
CLS 최적화:
<!-- 이미지 크기 명시 -->
<img src="/hero.jpg" width="1200" height="630" alt="..." />
<!-- CSS aspect-ratio로 공간 예약 -->
<style>
.embed { aspect-ratio: 16 / 9; }
</style>
<!-- Font fallback 크기 조정 -->
<style>
@font-face {
font-family: 'Inter';
src: url('inter.woff2') format('woff2');
font-display: optional; /* CLS 방지, 폰트 로드 안 되면 fallback 유지 */
size-adjust: 107%; /* fallback과 크기 일치시키기 */
}
</style>
<!-- Skeleton / Placeholder -->
<div class="skeleton" style="min-height: 400px;">Loading...</div>
INP — Interaction to Next Paint (2024.03 공식화)
정의: 사용자가 클릭/탭/키보드 입력을 했을 때, 다음 프레임이 그려지기까지의 시간 — 페이지 전체 수명 동안 최악(가장 느린) 상호작용 기준(98 percentile 가까움).
기준: 200ms 이하 = Good, 200-500ms = Needs Improvement, 500ms 초과 = Poor.
INP가 FID를 왜 대체했나:
- **FID(First Input Delay)**는 첫 입력의 '시작 지연'만 측정 (입력 → 핸들러 시작).
- 하지만 실제 UX 문제는 입력 후 → 화면 반영까지 전체 시간. 긴 JS 작업, 리렌더링, Layout, Paint 모두 포함해야 함.
- FID는 대부분 사이트가 100ms 이내 통과 → 변별력 없음. INP는 훨씬 엄격.
INP 공식 (간소화):
INP = max(interactions) where interaction_time =
(input delay) + (processing time) + (presentation delay)
INP 측정:
import { onINP } from 'web-vitals'
onINP((metric) => {
console.log('INP:', metric.value, 'ms')
console.log('Attribution:', metric.attribution)
// attribution: { interactionType, eventTarget, loafTime, ... }
})
INP가 나빠지는 주범 7가지:
- Long Task — 50ms 초과 메인 스레드 블로킹 JS.
- 큰 React 컴포넌트 리렌더 — state 업데이트 → 전체 subtree 재렌더.
- 동기 네트워크 요청 — fetch를 event handler에서 await.
- 대규모 DOM — 수천 개 노드의 Layout 재계산.
- heavy CSS selector —
:has(), 복잡한 nth-child. - 동기 third-party 스크립트 — 광고, 분석 도구.
- ResizeObserver/MutationObserver 폭주 — 콜백이 동기 Layout 유발.
INP 최적화 — Long Task 쪼개기:
// 나쁨 — 1000개 아이템을 한 번에 처리 (800ms Long Task)
function processItems(items) {
items.forEach(item => expensiveWork(item))
}
// 좋음 — scheduler.yield() (2024 표준화)
async function processItems(items) {
for (const item of items) {
expensiveWork(item)
await scheduler.yield() // 메인 스레드 양보
}
}
// 대체 — setTimeout으로 양보 (구형 브라우저)
function processItemsYield(items, i = 0) {
const deadline = performance.now() + 10
while (i < items.length && performance.now() < deadline) {
expensiveWork(items[i++])
}
if (i < items.length) {
setTimeout(() => processItemsYield(items, i), 0)
}
}
React INP 특화 — useTransition:
import { useTransition, useState } from 'react'
function SearchBox() {
const [isPending, startTransition] = useTransition()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
function handleChange(e) {
setQuery(e.target.value) // 긴급 업데이트 (input value)
startTransition(() => {
setResults(expensiveSearch(e.target.value)) // 낮은 우선순위
})
}
// ...
}
RUM vs Lab Data — 왜 Lighthouse는 자주 틀리는가
웹 성능 데이터에는 크게 두 종류가 있다:
Lab Data (합성 모니터링, Synthetic)
- Lighthouse, WebPageTest, PageSpeed Insights(Lab 탭)
- 통제된 환경에서 1회 측정
- 장점: 재현 가능, 회귀 탐지 쉬움, CI/CD에 통합 가능
- 단점: 실제 사용자 네트워크/디바이스/상호작용과 다름
Field Data / RUM (Real User Monitoring)
- Chrome UX Report(CrUX), Vercel Analytics, Sentry Performance, New Relic Browser, SpeedCurve
- 실제 사용자 브라우저에서 Performance API로 수집
- 장점: 실제 UX 반영, 디바이스/네트워크 분포 반영
- 단점: 노이즈 많음, 디버깅 어려움 (어떤 상호작용이 INP 80ms인지 추적 필요)
왜 Lighthouse 점수와 실제 점수가 다른가:
- Lighthouse는 Moto G Power + 4G 시뮬레이션 고정. 실제 유저는 iPhone 15 + 5G일 수도.
- Lighthouse는 페이지 로드만 측정 → INP는 세션 전체 상호작용 기반 → Lab에선 측정 안 됨.
- Lighthouse는 1회 측정 → 실제는 분포. Google은 CrUX의 75 percentile을 기준 삼음.
- Lighthouse는 쿠키/로그인 없음 → 실제는 인증된 사용자 화면이 다름.
- Lighthouse는 viewport 고정 360×640 → 실제 디바이스 폭 분포와 다름.
권장 전략:
- Lab Data (Lighthouse): PR마다 회귀 테스트 (Lighthouse CI), 상한선 설정
- RUM: 프로덕션 모니터링, p75 / p95 지표 추적, 국가별/디바이스별 드릴다운
- 두 데이터가 일치하지 않을 때 RUM을 믿어라
2025년 RUM 스택
| 제공자 | 특징 | 가격(참고) |
|---|---|---|
| Vercel Speed Insights | Next.js 통합, Core Web Vitals + Custom Events | 10,000 events 무료 |
| Google CrUX | 월별 공개 데이터, BigQuery | 무료 |
| Sentry Performance | 에러 + 성능 통합 | $26/mo부터 |
| SpeedCurve | 경쟁사 비교, 커스텀 대시보드 | $149/mo부터 |
| New Relic Browser | APM 통합 | Free tier |
| Cloudflare Web Analytics | 서버리스, 프라이버시 우선 | 무료 |
| Pingdom RUM | 지리적 분포 강점 | $14.95/mo부터 |
리소스 우선순위와 로딩 전략
브라우저가 100개 리소스를 동시에 받는 건 아니다. Fetch Priority, Preload Scanner, HTTP/2 Priority, HTTP/3 Priority가 복잡하게 얽혀 결정한다.
Resource Hints — 브라우저에 미리 알리기
<!-- DNS 미리 해석 -->
<link rel="dns-prefetch" href="https://api.example.com" />
<!-- 연결(DNS + TCP + TLS) 미리 열기 -->
<link rel="preconnect" href="https://api.example.com" crossorigin />
<!-- 리소스 미리 다운로드 (현재 페이지용) -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high" />
<link rel="preload" href="/main.js" as="script" />
<link rel="preload" href="/inter.woff2" as="font" type="font/woff2" crossorigin />
<!-- 다음 페이지 미리 가져오기 (low priority) -->
<link rel="prefetch" href="/next-page.html" />
<!-- 전체 페이지 미리 렌더링 (Speculation Rules로 대체됨) -->
<link rel="prerender" href="/next-page.html" /> <!-- Deprecated -->
Fetch Priority API (Chrome 101+, Safari 17+, Firefox 132+)
<!-- LCP 이미지 -->
<img src="/hero.webp" fetchpriority="high" />
<!-- 아래 스크롤 영역 이미지 -->
<img src="/below-fold.jpg" fetchpriority="low" loading="lazy" />
<!-- fetch() API -->
<script>
fetch('/critical.json', { priority: 'high' })
fetch('/analytics.json', { priority: 'low' })
</script>
Speculation Rules API — 2024 표준화
기존 <link rel="prefetch">와 prerender의 한계를 넘어, CSS selector 기반으로 사용자가 방문할 가능성이 높은 링크를 미리 prerender.
<script type="speculationrules">
{
"prerender": [{
"urls": ["/product/1", "/product/2"],
"eagerness": "moderate"
}],
"prefetch": [{
"where": { "href_matches": "/product/*" },
"eagerness": "conservative"
}]
}
</script>
eagerness 레벨:
immediate— 즉시 (aggressive)eager— 힌트 발견 즉시moderate— 링크에 hover/touch 등conservative— 링크 클릭 직전
Chrome 121+, 실질적 LCP 0ms 달성이 가능. (prerender된 페이지 전환 시 즉시 표시)
Early Hints (HTTP 103)
서버가 최종 응답(200) 전에 103 Early Hints 상태코드로 Link: </main.css>; rel=preload 힌트를 먼저 보내는 기술.
HTTP/1.1 103 Early Hints
Link: </main.css>; rel=preload; as=style
Link: </hero.webp>; rel=preload; as=image
HTTP/1.1 200 OK
Content-Type: text/html
...
Cloudflare, Fastly, Next.js(14.1+)가 지원. TTFB를 기다리지 않고 핵심 리소스를 미리 받음 → LCP 200-400ms 단축 가능.
이미지 최적화 — 모든 웹사이트 대역폭의 50%
Chrome UX Report에 따르면 평균 웹페이지의 이미지는 전체 바이트의 48%. 이미지 최적화 하나로 LCP를 1초 이상 단축할 수 있다.
포맷 선택
| 포맷 | 지원 | 특징 | 압축률 |
|---|---|---|---|
| JPEG | 100% | 사진, 손실 압축 | 기준 |
| PNG | 100% | 투명도, 무손실 | 크기 큼 |
| WebP | 97% (IE 제외) | Google, 25-35% 작음 | JPEG 대비 25-35% 작음 |
| AVIF | 93% | AV1 코덱 기반, 50% 작음 | JPEG 대비 50% 작음 |
| JPEG XL | Safari only (실험적) | 미래 표준 후보 | AVIF와 비슷 |
2025년 권장: <picture>로 AVIF → WebP → JPEG fallback.
<picture>
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" width="1200" height="630" alt="..." loading="lazy" decoding="async" />
</picture>
Responsive Images — srcset + sizes
<img
src="/hero-800.jpg"
srcset="/hero-400.jpg 400w,
/hero-800.jpg 800w,
/hero-1600.jpg 1600w,
/hero-2400.jpg 2400w"
sizes="(max-width: 768px) 100vw,
(max-width: 1200px) 50vw,
33vw"
width="800" height="600"
alt="Hero"
/>
현대 CDN — 자동 포맷 변환
- Cloudinary — URL 기반 변환 (
w_800,f_auto,q_auto) - imgix — 동적 파라미터
- Cloudflare Images — $5/월 100k 이미지
- Next.js Image —
<Image />컴포넌트 (AVIF/WebP 자동) - Vercel Image Optimization — 빌드 타임 + 온디맨드
Lazy Loading
<!-- 기본 lazy loading (Chrome 77+) -->
<img src="/hero.jpg" loading="lazy" />
<!-- IntersectionObserver 기반 커스텀 -->
<script>
const images = document.querySelectorAll('img[data-src]')
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.src = entry.target.dataset.src
observer.unobserve(entry.target)
}
})
}, { rootMargin: '200px' }) // 200px 전에 미리 로드
images.forEach(img => observer.observe(img))
</script>
주의: LCP 이미지는 절대 lazy loading 하지 말 것. loading="eager" + fetchpriority="high".
폰트 최적화 — FOIT/FOUT와 CLS의 주범
웹 폰트는 다운로드 전까지 텍스트가 안 보이거나(FOIT), fallback으로 보이다 갑자기 바뀐다(FOUT) — 모두 사용자 경험을 해친다.
font-display 전략
@font-face {
font-family: 'Inter';
src: url('inter.woff2') format('woff2');
font-display: swap; /* FOUT: fallback 보여주다 폰트 로드되면 교체 — CLS 발생 */
font-display: optional; /* 100ms 안에 안 오면 fallback 유지 — CLS 0 */
font-display: block; /* 3초까지 기다림 — FOIT */
font-display: fallback; /* 100ms + 3초 */
}
권장: LCP 텍스트는 font-display: optional + size-adjust로 fallback 크기 맞추기.
Font Fallback 크기 맞추기 (size-adjust)
@font-face {
font-family: 'Inter';
src: url('inter.woff2') format('woff2');
font-display: optional;
}
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
size-adjust: 107.4%; /* Arial을 Inter 크기로 조정 */
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter-fallback', sans-serif;
}
도구: Font Style Matcher, Fontaine(Vite plugin).
Preload + Subset
<!-- 필수 폰트 preload -->
<link rel="preload" href="/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
Subset: 한국어 폰트(Noto Sans KR, Pretendard)는 전체 글리프가 3-10MB. unicode-range로 한국어 영역만 로드.
@font-face {
font-family: 'Pretendard';
src: url('Pretendard-KR.woff2') format('woff2');
unicode-range: U+AC00-D7A3, U+1100-11FF, U+3130-318F; /* 한글 영역만 */
}
JavaScript 로딩 전략
JS는 웹 성능의 가장 큰 적이자 친구다. 현대 SPA는 평균 400KB gzipped JS를 로드 — 모바일에서 파싱/컴파일만 800ms+.
async vs defer vs module
<!-- Blocking (절대 쓰지 말 것) -->
<script src="/main.js"></script>
<!-- Async: 다운로드 완료 즉시 실행, 순서 보장 X -->
<script src="/analytics.js" async></script>
<!-- Defer: 다운로드 병렬, HTML 파싱 끝나고 실행, 순서 보장 -->
<script src="/main.js" defer></script>
<!-- Module: 기본 defer, 순서 보장 -->
<script src="/app.js" type="module"></script>
Code Splitting
Webpack/Rollup/esbuild 모두 지원. 라우트/컴포넌트 단위로 JS를 쪼개 초기 로드량 감소.
// React.lazy
const Heavy = React.lazy(() => import('./HeavyComponent'))
function App() {
return (
<Suspense fallback={<Skeleton />}>
<Heavy />
</Suspense>
)
}
// Next.js dynamic
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })
Tree Shaking
ESM import 구문을 분석해 사용 안 한 코드를 제거. side-effect free 선언 필수.
// package.json
{
"sideEffects": false,
"exports": {
".": "./dist/index.js"
}
}
Third-Party Scripts — 가장 큰 적
Google Analytics, Facebook Pixel, Intercom 등 3rd party 스크립트 하나가 INP 500ms를 만든다.
Partytown (Builder.io) — 3rd party 스크립트를 Web Worker에서 실행.
<script src="https://cdn.jsdelivr.net/npm/@builder.io/partytown/lib/partytown.js"></script>
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js?id=GA_ID"></script>
Next.js Script 컴포넌트:
import Script from 'next/script'
<Script src="https://analytics.example.com" strategy="lazyOnload" />
<Script src="https://critical.example.com" strategy="beforeInteractive" />
Long Task와 메인 스레드 예산
Long Task: 50ms 초과로 메인 스레드를 블로킹하는 JS 작업. INP를 망치는 최대 원인.
Long Task 감지
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn('Long Task:', entry.duration, 'ms', entry.attribution)
}
}).observe({ type: 'longtask', buffered: true })
Long Animation Frames (LoAF) — 2024 신규
Long Task의 한계를 보완한 새 API. 프레임 단위로 렌더링 + 스크립트 + Layout + Paint 시간 분석.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LoAF:', {
duration: entry.duration,
scripts: entry.scripts, // 어떤 스크립트가 얼마나 걸렸나
blockingDuration: entry.blockingDuration,
})
}
}).observe({ type: 'long-animation-frame', buffered: true })
scheduler.yield() — 메인 스레드 양보
async function bulkWork(items) {
for (const item of items) {
process(item)
if (navigator.scheduling?.isInputPending()) {
await scheduler.yield() // 입력 대기 중이면 즉시 양보
}
}
}
Web Worker — CPU 오프로딩
// main.js
const worker = new Worker('/worker.js')
worker.postMessage({ cmd: 'hash', data: largeString })
worker.onmessage = (e) => console.log('hashed:', e.data)
// worker.js
self.onmessage = async (e) => {
const buf = new TextEncoder().encode(e.data.data)
const hash = await crypto.subtle.digest('SHA-256', buf)
self.postMessage(Array.from(new Uint8Array(hash)))
}
Hydration 문제 — SPA의 근본 비용
React/Vue/Angular 같은 SPA는 Hydration — 서버에서 렌더된 HTML에 JS를 붙여 상호작용 가능하게 만드는 과정 — 이 INP를 망친다.
Hydration의 6단계 비용 (Addy Osmani)
- JS 다운로드 — 200-500KB gzipped
- JS 파싱 + 컴파일
- React Tree 재구성 (서버 HTML 무관)
- 이벤트 리스너 부착
- useState/useEffect 실행
- Commit
모바일에서 이 전체가 1-3초 걸린다. 그동안 사용자 클릭은 무시.
해결책 1: Partial Hydration (Islands)
Astro, Marko, Fresh(Deno)의 접근. 페이지 대부분은 정적 HTML, 상호작용이 필요한 부분만 섬(island)으로 Hydrate.
---
// Astro 파일
import Counter from './Counter.tsx'
---
<html>
<body>
<h1>정적 콘텐츠 (Hydrate 안 함)</h1>
<Counter client:visible /> <!-- 뷰포트 진입 시 Hydrate -->
<Counter client:idle /> <!-- 아이들 상태일 때 -->
<Counter client:load /> <!-- 즉시 -->
</body>
</html>
해결책 2: Resumability (Qwik)
Qwik의 혁신: Hydration 자체를 없애고, HTML에 serialize된 상태를 사용자 상호작용 시점에 재개(resume).
// Qwik 컴포넌트
export default component$(() => {
const count = useSignal(0)
return (
<button onClick$={() => count.value++}>
{count.value}
</button>
)
})
HTML에 핸들러 URL을 임베드:
<button on:click="app.js#Counter_onClick[0]">0</button>
JS 0KB로 시작, 클릭 시점에만 해당 핸들러 JS를 lazy load. TTI = LCP를 실현.
해결책 3: React Server Components + Streaming
React 18 + Next.js 14. 서버 컴포넌트는 JS 번들에 포함 안 됨 → 클라이언트 번들 감소. Streaming으로 <Suspense> 경계까지 먼저 렌더 → LCP 단축.
해결책 4: Selective Hydration
React 18 기본 기능. <Suspense> 경계를 우선순위 기반으로 Hydrate. 사용자가 클릭한 영역 우선 처리.
HTTP/3, QUIC, 그리고 네트워크 계층
2024년 HTTP/3가 전체 트래픽의 **30%**를 넘어섰다(W3Techs). TCP 위에서 동작하는 HTTP/2와 달리, HTTP/3는 UDP 기반 QUIC 프로토콜 위에서 동작한다.
HTTP/1.1 → HTTP/2 → HTTP/3
| 버전 | 기반 | 멀티플렉싱 | Head-of-Line Blocking | 0-RTT |
|---|---|---|---|---|
| HTTP/1.1 | TCP | X (6 connections/origin) | Yes | No |
| HTTP/2 | TCP | Yes | TCP 수준 Yes | No |
| HTTP/3 | UDP (QUIC) | Yes | No | Yes |
HTTP/3 핵심 이득:
- 0-RTT Resumption — 이전 연결 키 재사용, 첫 요청부터 데이터 전송
- Connection Migration — WiFi → 셀룰러 전환해도 연결 유지 (Connection ID)
- HOL Blocking 해소 — 한 스트림 패킷 손실이 다른 스트림 차단 안 함
- 암호화 필수 — TLS 1.3 내장, 평문 불가
실제 측정 (Cloudflare 2024 자료):
- Google Search: HTTP/3로 중앙값 응답 시간 3% 단축, 상위 10% 구간 10% 단축
- Facebook: 동영상 rebuffering 20% 감소
- Akamai: TTFB 모바일 12% 개선
CDN + Edge 컴퓨팅
Cloudflare, Fastly, AWS CloudFront, Vercel Edge Network, Bunny.net. 콘텐츠를 사용자 가까이 캐시해 지연 최소화.
2025년 트렌드:
- Edge Workers — Cloudflare Workers, Deno Deploy, Vercel Edge Functions. V8 Isolate 기반 수 ms 콜드 스타트.
- Regional Edge Cache — 기존 Origin → Edge 2단계를 3단계로 (Origin → Regional → Edge).
- Smart Placement (Cloudflare) — 사용자 기반이 아닌 Origin 가까이 배치해 DB 지연 최소화.
2025년 성능 도구 스택
측정 도구
- Chrome DevTools Performance — 가장 기본. 2024년 Performance Insights 패널 추가로 Core Web Vitals 실시간 분석.
- Lighthouse — Chrome 내장, CI용
lighthouse-ci, Vercel/Netlify 통합. - WebPageTest — 상세 분석, Filmstrip, 연결 상세. 무료 + 유료 플랜.
- PageSpeed Insights — Lab(Lighthouse) + Field(CrUX) 통합 뷰.
- Chrome UX Report — 월간 공개, BigQuery로 경쟁사 비교.
프로파일러
- SpeedScope — https://www.speedscope.app, 불꽃 그래프 시각화, Chrome Performance 프로파일 import.
- Perfetto — Chrome DevTools와 Chromium 내부 추적, UI 공유.
- React DevTools Profiler — 컴포넌트 렌더 시간 분해.
- Next.js Build Analyzer —
@next/bundle-analyzer, 번들 크기 시각화.
RUM
- Vercel Speed Insights + Web Analytics — Next.js 기본.
- Sentry Performance — 에러 + RUM 통합.
- New Relic Browser — APM 연동.
- Cloudflare Web Analytics — 무료, 프라이버시 우선.
- SpeedCurve — 경쟁사 비교에 강점.
최적화 도구
- Next.js Image + Vercel Image Optimization — AVIF/WebP 자동.
- Sharp (Node.js) — 서버 사이드 이미지 변환.
- Partytown — 3rd party 스크립트 Worker 격리.
- Fontaine (Vite plugin) — fallback 폰트 자동 생성.
- Critical (Addy Osmani) — Critical CSS 추출.
실전 최적화 체크리스트 (2025)
실제 웹사이트를 최적화할 때 순서:
- RUM 도입 — Vercel Speed Insights 또는 web-vitals 라이브러리로 실제 지표 측정
- CDN + HTTP/3 + Brotli — 네트워크 계층
- 서버 TTFB 200ms 이하 — DB 쿼리 최적화, SSR 캐싱, Edge Function
- LCP 이미지 최적화 — AVIF +
fetchpriority="high"+preload - Critical CSS 인라인 + 나머지 deferred —
media="print"해킹 또는 Critical 라이브러리 - Font Loading —
font-display: optional+ size-adjust fallback - JS Code Splitting — 라우트 단위 + React.lazy
- 3rd Party Scripts 감사 — Partytown,
next/scriptstrategy="lazyOnload" - CLS 제거 — 이미지/iframe width/height, Ad 슬롯 aspect-ratio, 폰트 fallback 매칭
- INP 최적화 — Long Task 쪼개기 (scheduler.yield), useTransition, Web Worker
- Speculation Rules — 예측 가능한 다음 페이지 prerender
- 회귀 방지 — Lighthouse CI, Performance Budget (webpack/rollup plugin)
10가지 흔한 안티패턴
- LCP 이미지에
loading="lazy"적용 — LCP 영구 지연. - Fonts 없이 커스텀 폰트 사용 — FOIT 3초, 빈 화면.
- React 전체 Hydration + 정적 사이트 — Astro/Next SSG를 안 쓰고 Next.js CSR.
- Third-party 스크립트 blocking 로드 — GA/GTM을 head에 그냥 넣기.
- 클라이언트에서 Markdown 렌더링 — 서버에서 미리 HTML로 변환해야.
- Lighthouse 점수만 모니터링 — RUM 없이 실제 사용자 경험 맹목.
- Layout Thrashing —
for루프 안에서offsetHeight반복 읽고 쓰기. - 거대 이미지 원본 로드 — 4K 이미지를 200px 썸네일에 사용.
- 동기
fetch를 event handler에 await — INP 크게 악화. - Hydration 중 state 업데이트 — 무한 Hydration/Rerender 루프.
다음 글 예고 — 데이터베이스의 새 물결 — PostgreSQL, pgvector, pg_vector, HNSW, AI 시대의 DB 전략
웹 성능 최적화의 종착지는 보통 데이터베이스다. 아무리 CDN 잘 써도 DB 쿼리가 느리면 TTFB가 망가진다. 2023-2025년 데이터베이스 세계의 가장 큰 사건은 PostgreSQL의 벡터 데이터베이스 정복이었다. pgvector 확장이 Pinecone, Weaviate, Qdrant 같은 전용 벡터 DB를 위협하며 '만능 DB로서의 PostgreSQL' 시대를 열었다.
다음 글에서는:
- PostgreSQL 왜 다시 1등인가 — StackOverflow 2024 개발자 설문 1위
- pgvector와 HNSW 인덱스 — 벡터 검색의 수학과 실제
- pgvector vs Pinecone vs Weaviate vs Qdrant — 성능/기능/비용 비교
- PostgreSQL 17의 비약 — Logical Replication, Incremental Backup
- Supabase, Neon, PlanetScale, CockroachDB — 클라우드 PostgreSQL 생태계
- JSON, JSONB, GIN index — NoSQL 기능 완벽 통합
- MVCC 원리 — 낙관적 동시성의 우아함
- Citus, TimescaleDB, PostGIS — 확장 생태계
- PostgreSQL + AI — RAG 파이프라인 실전
...을 다룬다. '하나의 DB로 모든 것'이 현실이 된 시대, 그 배경과 실전 설계를 살펴본다. 웹 성능의 여정이 데이터 계층으로 이어지는 이유를 추적해보자.
The Science of Modern Web Performance — A Deep Dive into Core Web Vitals, INP, LCP, CLS, RUM, Lighthouse, Critical Rendering Path, and Speculation Rules (2025)
TL;DR — 2024 was a tectonic shift for web performance. INP (Interaction to Next Paint) replaced FID, reshaping the three Core Web Vitals into LCP, CLS, and INP, while Speculation Rules API (Chrome 121), Partial Prerendering (Next.js 14), HTTP/3 QUIC (past 30% of all traffic), and Early Hints (103 Status) all standardized and went mainstream in the same year. To answer "why is my site slow," this post covers the Critical Rendering Path from first principles, breaking up Long Tasks, RUM vs Lab Data, why Lighthouse is often wrong, Islands and Resumability (Qwik), Image/Font optimization (AVIF,
font-display: optional), and the 2025 performance tooling stack (Vercel Analytics, SpeedCurve, Perfetto) — a full landscape of modern web performance.
Why Web Performance Became a Hot Topic Again
Web performance became mainstream in the early 2010s with YSlow (Yahoo) and PageSpeed Insights (Google), but then faded into "infra team checklist" territory for years. Then in 2020, Google officially announced Core Web Vitals as a search ranking signal, and in 2024 INP replaced FID — performance now directly drives search rank, ad conversion, and bounce rate as a business metric.
By the numbers:
- Amazon: Every 100ms of page load delay costs 1% in revenue (2006 data, more sensitive in 2024)
- Walmart: 1-second faster LCP → 2% conversion lift
- BBC: 10% more bounces per second of delay
- Vodafone: 31% LCP improvement → 8% sales conversion lift (2021 case study)
According to the 2024 Chrome UX Report (CrUX, real-user data), only 42% of the world's top 1M sites pass all three Core Web Vitals. Sites built on SPA frameworks like React/Vue/Angular pass at only 28%, far below static sites (65%). Performance is also the price of your framework choice.
This post walks through the definitions and measurement of the three Core Web Vitals, why the same code is fast in Lab and slow in Field, and how to track and fix Long Tasks and Layout Shifts — from first principles to practice. Performance doesn't come from "a single trick" but from understanding the entire rendering pipeline.
Browser Rendering Pipeline — The Journey to a Pixel
To discuss web performance, you need to understand the Critical Rendering Path — how the browser takes HTML and draws pixels. Every place time leaks along this path is a performance bug.
1. Navigation — URL entered / link clicked
↓
2. DNS Lookup — example.com → 93.184.216.34
↓
3. TCP + TLS Handshake — 3-way + SSL (HTTP/1.1: ~300ms, HTTP/3: ~100ms)
↓
4. HTTP Request — GET /
↓
5. TTFB — Time To First Byte (server response)
↓
6. HTML Parsing — Build DOM tree (fetches external resources during parsing)
↓
7. CSSOM Construction — Parse CSS, build CSSOM tree
↓
8. Render Tree — DOM + CSSOM → only nodes that render
↓
9. Layout (Reflow) — Calculate position/size of each node
↓
10. Paint — Generate pixel info (per layer)
↓
11. Composite — GPU composites layers
↓
12. Display — First pixels the user sees (FCP)
Key bottlenecks at each step:
- DNS + TCP + TLS — 3-4 RTTs (Round Trip Time) before the first byte. This is what HTTP/3 QUIC (0-RTT resumption) attacks.
- TTFB — Server response time. For SSR: DB + rendering; for static files: whether CDN cache hits.
- HTML Parsing Blocking —
<script>tags block parsing by default. Solved byasync/defer. - CSSOM Blocking — CSS is a render-blocking resource. The Render Tree is not built until CSS finishes loading.
- Layout — Forced synchronous layout (e.g. reading
offsetHeight) is a performance killer. - Paint / Composite — Use
will-change: transformandcontain: layoutto trigger GPU layer separation.
With JS frameworks like React/Vue, add JS download, parsing, execution, and Hydration on top. These extra steps are the root reason SPAs struggle with Core Web Vitals.
The Three Core Web Vitals (2024–2025)
Google defined Core Web Vitals in 2020 as the three pillars of user experience:
- Loading — LCP
- Interactivity — FID → INP (replaced in March 2024)
- Visual Stability — CLS
LCP — Largest Contentful Paint (Loading)
Definition: The time when the largest content element visible in the viewport (image, video poster, block of text) is drawn.
Thresholds: <2.5s = Good, 2.5–4.0s = Needs Improvement, >4.0s = Poor.
Measurement: LargestContentfulPaint PerformanceObserver API.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP element:', entry.element)
console.log('LCP time:', entry.startTime)
console.log('LCP render time:', entry.renderTime)
console.log('LCP size:', entry.size)
}
}).observe({ type: 'largest-contentful-paint', buffered: true })
The four main culprits for slow LCP:
- Slow TTFB — If the server takes 1 second, passing
<2.5sLCP is impossible. - Render-blocking resources — A delayed
<link rel="stylesheet">delays LCP directly. - Resource load time — The LCP image must not be lazy-loaded. Use
fetchpriority="high". - Client-side rendering — With React, the LCP element doesn't hit the DOM until JS downloads and Hydration completes.
LCP optimization checklist:
- LCP image gets
fetchpriority="high"+loading="eager"+decoding="async" <link rel="preload" as="image" href="/hero.webp" imagesrcset="..." fetchpriority="high">- Above-the-fold content uses inline CSS
- Fonts with
font-display: optionalorswap - CDN + HTTP/3 + Brotli compression
- SSR or SSG (avoid CSR)
CLS — Cumulative Layout Shift (Visual Stability)
Definition: How much the layout unexpectedly shifts during page load. Accumulated "impact fraction" times "distance fraction" of the moved element.
Thresholds: <0.1 = Good, 0.1–0.25 = Needs Improvement, >0.25 = Poor.
CLS formula: impact fraction × distance fraction
- Impact Fraction: Ratio of moved element's area to the viewport
- Distance Fraction: Distance moved / viewport size
The five main CLS culprits:
- Unsized images — Missing width/height on
<img>→ layout reflows after the image loads. - Unsized ads/embeds — AdSense, YouTube embed, iframe.
- FOIT/FOUT — Text height changes when fonts swap.
- Dynamic content injection — Banners/alerts inserted at the top.
- Web font load delay —
font-display: swapitself causes CLS (ironically).
CLS optimization:
<!-- Specify image size -->
<img src="/hero.jpg" width="1200" height="630" alt="..." />
<!-- Reserve space with CSS aspect-ratio -->
<style>
.embed { aspect-ratio: 16 / 9; }
</style>
<!-- Adjust font fallback size -->
<style>
@font-face {
font-family: 'Inter';
src: url('inter.woff2') format('woff2');
font-display: optional; /* Prevent CLS, keep fallback if font doesn't load */
size-adjust: 107%; /* Match fallback size */
}
</style>
<!-- Skeleton / Placeholder -->
<div class="skeleton" style="min-height: 400px;">Loading...</div>
INP — Interaction to Next Paint (Officialized March 2024)
Definition: The time from when a user clicks/taps/types until the next frame is drawn — measured as the worst (slowest) interaction over the page's entire lifetime (close to the 98th percentile).
Thresholds: <200ms = Good, 200–500ms = Needs Improvement, >500ms = Poor.
Why INP replaced FID:
- FID (First Input Delay) measures only the "start delay" of the first input (input → handler start).
- But real UX problems are the total time from input → screen update. Long JS tasks, re-renders, Layout, and Paint all need to count.
- FID passed
<100mson most sites → no discriminating power. INP is much stricter.
INP formula (simplified):
INP = max(interactions) where interaction_time =
(input delay) + (processing time) + (presentation delay)
Measuring INP:
import { onINP } from 'web-vitals'
onINP((metric) => {
console.log('INP:', metric.value, 'ms')
console.log('Attribution:', metric.attribution)
// attribution: { interactionType, eventTarget, loafTime, ... }
})
Seven main culprits for bad INP:
- Long Task — JS that blocks the main thread for
>50ms. - Large React component re-renders — A state update re-renders the whole subtree.
- Synchronous network requests — Awaiting fetch in an event handler.
- Large DOM — Layout recalculation across thousands of nodes.
- Heavy CSS selectors —
:has(), complex nth-child. - Synchronous third-party scripts — Ads, analytics.
- ResizeObserver/MutationObserver storms — Callbacks triggering synchronous Layout.
Optimizing INP — breaking up Long Tasks:
// Bad — process 1000 items at once (800ms Long Task)
function processItems(items) {
items.forEach(item => expensiveWork(item))
}
// Good — scheduler.yield() (standardized in 2024)
async function processItems(items) {
for (const item of items) {
expensiveWork(item)
await scheduler.yield() // Yield to main thread
}
}
// Alternative — yield via setTimeout (for older browsers)
function processItemsYield(items, i = 0) {
const deadline = performance.now() + 10
while (i < items.length && performance.now() < deadline) {
expensiveWork(items[i++])
}
if (i < items.length) {
setTimeout(() => processItemsYield(items, i), 0)
}
}
React-specific INP — useTransition:
import { useTransition, useState } from 'react'
function SearchBox() {
const [isPending, startTransition] = useTransition()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
function handleChange(e) {
setQuery(e.target.value) // Urgent update (input value)
startTransition(() => {
setResults(expensiveSearch(e.target.value)) // Lower priority
})
}
// ...
}
RUM vs Lab Data — Why Lighthouse Is Often Wrong
There are two broad kinds of web performance data:
Lab Data (Synthetic Monitoring)
- Lighthouse, WebPageTest, PageSpeed Insights (Lab tab)
- Single measurement in a controlled environment
- Pros: Reproducible, easy regression detection, CI/CD integration
- Cons: Differs from real user networks, devices, and interactions
Field Data / RUM (Real User Monitoring)
- Chrome UX Report (CrUX), Vercel Analytics, Sentry Performance, New Relic Browser, SpeedCurve
- Collected from real user browsers via Performance API
- Pros: Reflects real UX, captures device/network distribution
- Cons: Noisy, hard to debug (you need to trace which interaction caused INP 80ms)
Why Lighthouse scores differ from real scores:
- Lighthouse is fixed to Moto G Power + 4G simulation. Your real user might be on iPhone 15 + 5G.
- Lighthouse only measures page load → INP is based on session-wide interaction → not measurable in Lab.
- Lighthouse is a single run → real data is a distribution. Google uses CrUX's 75th percentile.
- Lighthouse has no cookies/login → real authenticated pages look different.
- Lighthouse has a fixed 360×640 viewport → differs from real device width distribution.
Recommended strategy:
- Lab Data (Lighthouse): PR-level regression testing (Lighthouse CI), set upper bounds
- RUM: Production monitoring, track p75/p95, drill down by country/device
- When the two disagree, trust RUM
The 2025 RUM Stack
| Provider | Notes | Pricing (ref) |
|---|---|---|
| Vercel Speed Insights | Next.js integration, Core Web Vitals + Custom Events | 10,000 events free |
| Google CrUX | Monthly public data, BigQuery | Free |
| Sentry Performance | Errors + perf integration | From $26/mo |
| SpeedCurve | Competitor comparison, custom dashboards | From $149/mo |
| New Relic Browser | APM integration | Free tier |
| Cloudflare Web Analytics | Serverless, privacy-first | Free |
| Pingdom RUM | Strong geographic coverage | From $14.95/mo |
Resource Priority and Loading Strategies
Browsers don't fetch 100 resources simultaneously. Fetch Priority, Preload Scanner, HTTP/2 Priority, and HTTP/3 Priority interleave to decide.
Resource Hints — Telling the Browser in Advance
<!-- Pre-resolve DNS -->
<link rel="dns-prefetch" href="https://api.example.com" />
<!-- Pre-open connection (DNS + TCP + TLS) -->
<link rel="preconnect" href="https://api.example.com" crossorigin />
<!-- Pre-download resource (for current page) -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high" />
<link rel="preload" href="/main.js" as="script" />
<link rel="preload" href="/inter.woff2" as="font" type="font/woff2" crossorigin />
<!-- Prefetch next page (low priority) -->
<link rel="prefetch" href="/next-page.html" />
<!-- Pre-render full page (superseded by Speculation Rules) -->
<link rel="prerender" href="/next-page.html" /> <!-- Deprecated -->
Fetch Priority API (Chrome 101+, Safari 17+, Firefox 132+)
<!-- LCP image -->
<img src="/hero.webp" fetchpriority="high" />
<!-- Below-the-fold image -->
<img src="/below-fold.jpg" fetchpriority="low" loading="lazy" />
<!-- fetch() API -->
<script>
fetch('/critical.json', { priority: 'high' })
fetch('/analytics.json', { priority: 'low' })
</script>
Speculation Rules API — Standardized in 2024
Going beyond the limits of <link rel="prefetch"> and prerender, this uses CSS selector–based rules to prerender links the user is likely to visit.
<script type="speculationrules">
{
"prerender": [{
"urls": ["/product/1", "/product/2"],
"eagerness": "moderate"
}],
"prefetch": [{
"where": { "href_matches": "/product/*" },
"eagerness": "conservative"
}]
}
</script>
Eagerness levels:
immediate— Right now (aggressive)eager— As soon as the hint is foundmoderate— On link hover/touchconservative— Right before click
Chrome 121+ can effectively deliver LCP of 0ms (instant display when navigating to a prerendered page).
Early Hints (HTTP 103)
A technique where the server sends a 103 Early Hints status with Link: </main.css>; rel=preload hints before the final 200 response.
HTTP/1.1 103 Early Hints
Link: </main.css>; rel=preload; as=style
Link: </hero.webp>; rel=preload; as=image
HTTP/1.1 200 OK
Content-Type: text/html
...
Supported by Cloudflare, Fastly, and Next.js (14.1+). Critical resources start fetching without waiting on TTFB → LCP drops by 200–400ms.
Image Optimization — 50% of All Web Bandwidth
Per the Chrome UX Report, images are 48% of total bytes on the average web page. Image optimization alone can cut LCP by more than 1 second.
Format Selection
| Format | Support | Notes | Compression |
|---|---|---|---|
| JPEG | 100% | Photos, lossy | Baseline |
| PNG | 100% | Transparency, lossless | Large |
| WebP | 97% (excl. IE) | Google, 25–35% smaller | 25–35% smaller than JPEG |
| AVIF | 93% | AV1 codec, 50% smaller | 50% smaller than JPEG |
| JPEG XL | Safari only (experimental) | Future candidate | Similar to AVIF |
2025 recommendation: Use <picture> with AVIF → WebP → JPEG fallback.
<picture>
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" width="1200" height="630" alt="..." loading="lazy" decoding="async" />
</picture>
Responsive Images — srcset + sizes
<img
src="/hero-800.jpg"
srcset="/hero-400.jpg 400w,
/hero-800.jpg 800w,
/hero-1600.jpg 1600w,
/hero-2400.jpg 2400w"
sizes="(max-width: 768px) 100vw,
(max-width: 1200px) 50vw,
33vw"
width="800" height="600"
alt="Hero"
/>
Modern CDNs — Automatic Format Conversion
- Cloudinary — URL-based transforms (
w_800,f_auto,q_auto) - imgix — Dynamic parameters
- Cloudflare Images — $5/mo for 100k images
- Next.js Image —
<Image />component (auto AVIF/WebP) - Vercel Image Optimization — Build time + on-demand
Lazy Loading
<!-- Native lazy loading (Chrome 77+) -->
<img src="/hero.jpg" loading="lazy" />
<!-- Custom via IntersectionObserver -->
<script>
const images = document.querySelectorAll('img[data-src]')
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.src = entry.target.dataset.src
observer.unobserve(entry.target)
}
})
}, { rootMargin: '200px' }) // Preload 200px before
images.forEach(img => observer.observe(img))
</script>
Warning: Never lazy-load the LCP image. Use loading="eager" + fetchpriority="high".
Font Optimization — The Main Offender Behind FOIT/FOUT and CLS
Until a web font downloads, text either isn't shown (FOIT) or shows a fallback that suddenly swaps (FOUT) — both hurt UX.
font-display Strategies
@font-face {
font-family: 'Inter';
src: url('inter.woff2') format('woff2');
font-display: swap; /* FOUT: show fallback then swap — causes CLS */
font-display: optional; /* Keep fallback if font doesn't arrive in 100ms — CLS 0 */
font-display: block; /* Wait up to 3s — FOIT */
font-display: fallback; /* 100ms + 3s */
}
Recommendation: Use font-display: optional + size-adjust for LCP text to match the fallback size.
Matching Fallback Size (size-adjust)
@font-face {
font-family: 'Inter';
src: url('inter.woff2') format('woff2');
font-display: optional;
}
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
size-adjust: 107.4%; /* Scale Arial to Inter's size */
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter-fallback', sans-serif;
}
Tools: Font Style Matcher, Fontaine (Vite plugin).
Preload + Subset
<!-- Preload critical fonts -->
<link rel="preload" href="/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
Subsetting: Korean fonts (Noto Sans KR, Pretendard) are 3–10MB with full glyphs. Use unicode-range to load only the Korean region.
@font-face {
font-family: 'Pretendard';
src: url('Pretendard-KR.woff2') format('woff2');
unicode-range: U+AC00-D7A3, U+1100-11FF, U+3130-318F; /* Hangul only */
}
JavaScript Loading Strategies
JS is the single biggest foe and friend of web performance. Modern SPAs ship around 400KB gzipped JS on average — just parse/compile alone takes >800ms on mobile.
async vs defer vs module
<!-- Blocking (never use) -->
<script src="/main.js"></script>
<!-- Async: execute as soon as downloaded, no order guarantee -->
<script src="/analytics.js" async></script>
<!-- Defer: download in parallel, execute after HTML parsing, order preserved -->
<script src="/main.js" defer></script>
<!-- Module: defer by default, order preserved -->
<script src="/app.js" type="module"></script>
Code Splitting
Supported by Webpack/Rollup/esbuild. Split JS per route/component to reduce initial load.
// React.lazy
const Heavy = React.lazy(() => import('./HeavyComponent'))
function App() {
return (
<Suspense fallback={<Skeleton />}>
<Heavy />
</Suspense>
)
}
// Next.js dynamic
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })
Tree Shaking
Analyze ESM imports to remove unused code. Declaring side-effect free is essential.
// package.json
{
"sideEffects": false,
"exports": {
".": "./dist/index.js"
}
}
Third-Party Scripts — The Biggest Offender
A single 3rd-party script like Google Analytics, Facebook Pixel, or Intercom can push INP to 500ms.
Partytown (Builder.io) — Run 3rd-party scripts inside a Web Worker.
<script src="https://cdn.jsdelivr.net/npm/@builder.io/partytown/lib/partytown.js"></script>
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js?id=GA_ID"></script>
Next.js Script component:
import Script from 'next/script'
<Script src="https://analytics.example.com" strategy="lazyOnload" />
<Script src="https://critical.example.com" strategy="beforeInteractive" />
Long Tasks and the Main Thread Budget
Long Task: A JS task that blocks the main thread for more than 50ms. The #1 killer of INP.
Detecting Long Tasks
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn('Long Task:', entry.duration, 'ms', entry.attribution)
}
}).observe({ type: 'longtask', buffered: true })
Long Animation Frames (LoAF) — New in 2024
A new API that addresses Long Task's limits. Breaks down rendering + scripts + Layout + Paint per frame.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LoAF:', {
duration: entry.duration,
scripts: entry.scripts, // which scripts took how long
blockingDuration: entry.blockingDuration,
})
}
}).observe({ type: 'long-animation-frame', buffered: true })
scheduler.yield() — Yielding to the Main Thread
async function bulkWork(items) {
for (const item of items) {
process(item)
if (navigator.scheduling?.isInputPending()) {
await scheduler.yield() // Yield immediately if input is pending
}
}
}
Web Worker — CPU Offloading
// main.js
const worker = new Worker('/worker.js')
worker.postMessage({ cmd: 'hash', data: largeString })
worker.onmessage = (e) => console.log('hashed:', e.data)
// worker.js
self.onmessage = async (e) => {
const buf = new TextEncoder().encode(e.data.data)
const hash = await crypto.subtle.digest('SHA-256', buf)
self.postMessage(Array.from(new Uint8Array(hash)))
}
The Hydration Problem — The Fundamental Cost of SPAs
SPAs like React/Vue/Angular have a Hydration step — attaching JS to server-rendered HTML to make it interactive — which wrecks INP.
Hydration's Six-Step Cost (Addy Osmani)
- JS download — 200–500KB gzipped
- JS parse + compile
- React tree reconstruction (independent of server HTML)
- Attach event listeners
- Run useState/useEffect
- Commit
On mobile this whole chain takes 1–3 seconds. User clicks during that window are ignored.
Solution 1: Partial Hydration (Islands)
The approach of Astro, Marko, and Fresh (Deno). Most of the page is static HTML; only the interactive parts are hydrated as islands.
---
// Astro file
import Counter from './Counter.tsx'
---
<html>
<body>
<h1>Static content (not hydrated)</h1>
<Counter client:visible /> <!-- Hydrate on viewport entry -->
<Counter client:idle /> <!-- When idle -->
<Counter client:load /> <!-- Immediately -->
</body>
</html>
Solution 2: Resumability (Qwik)
Qwik's innovation: eliminate Hydration entirely and resume from state serialized into HTML at the moment of user interaction.
// Qwik component
export default component$(() => {
const count = useSignal(0)
return (
<button onClick$={() => count.value++}>
{count.value}
</button>
)
})
The handler URL is embedded in HTML:
<button on:click="app.js#Counter_onClick[0]">0</button>
Start with 0KB of JS, lazy-load just the handler JS on click. Makes TTI = LCP reality.
Solution 3: React Server Components + Streaming
React 18 + Next.js 14. Server Components aren't in the JS bundle → smaller client bundle. Streaming renders up to <Suspense> boundaries first → faster LCP.
Solution 4: Selective Hydration
A React 18 built-in. Hydrates <Suspense> boundaries based on priority. The area the user clicks gets processed first.
HTTP/3, QUIC, and the Network Layer
HTTP/3 passed 30% of all traffic in 2024 (W3Techs). Unlike HTTP/2 which runs on TCP, HTTP/3 runs on UDP-based QUIC.
HTTP/1.1 → HTTP/2 → HTTP/3
| Version | Transport | Multiplexing | Head-of-Line Blocking | 0-RTT |
|---|---|---|---|---|
| HTTP/1.1 | TCP | No (6 connections/origin) | Yes | No |
| HTTP/2 | TCP | Yes | At TCP level Yes | No |
| HTTP/3 | UDP (QUIC) | Yes | No | Yes |
HTTP/3 core wins:
- 0-RTT Resumption — Reuse prior connection keys, send data on the first request
- Connection Migration — Connection survives WiFi → cellular switch (Connection ID)
- No HOL Blocking — A lost packet in one stream doesn't block others
- Mandatory encryption — TLS 1.3 built in, no cleartext
Real measurements (Cloudflare 2024):
- Google Search: 3% faster median with HTTP/3, 10% faster at the top 10%
- Facebook: 20% less video rebuffering
- Akamai: 12% better mobile TTFB
CDN + Edge Computing
Cloudflare, Fastly, AWS CloudFront, Vercel Edge Network, Bunny.net. Caching content close to users to minimize latency.
2025 trends:
- Edge Workers — Cloudflare Workers, Deno Deploy, Vercel Edge Functions. V8 Isolate–based with sub-ms cold start.
- Regional Edge Cache — Three tiers (Origin → Regional → Edge) instead of the classic two (Origin → Edge).
- Smart Placement (Cloudflare) — Place workers close to Origin, not users, to minimize DB latency.
The 2025 Performance Tooling Stack
Measurement Tools
- Chrome DevTools Performance — Most fundamental. The 2024 Performance Insights panel added real-time Core Web Vitals analysis.
- Lighthouse — Built into Chrome.
lighthouse-cifor CI, integrated with Vercel/Netlify. - WebPageTest — Deep analysis, filmstrip, connection details. Free + paid plans.
- PageSpeed Insights — Lab (Lighthouse) + Field (CrUX) combined view.
- Chrome UX Report — Public monthly, compare against competitors via BigQuery.
Profilers
- SpeedScope — https://www.speedscope.app, flame graph viz, imports Chrome Performance profiles.
- Perfetto — Chrome DevTools and Chromium-internal tracing, shareable UI.
- React DevTools Profiler — Component render-time breakdown.
- Next.js Build Analyzer —
@next/bundle-analyzer, bundle-size visualization.
RUM
- Vercel Speed Insights + Web Analytics — Default for Next.js.
- Sentry Performance — Error + RUM integration.
- New Relic Browser — APM integrated.
- Cloudflare Web Analytics — Free, privacy-first.
- SpeedCurve — Strong for competitor comparison.
Optimization Tools
- Next.js Image + Vercel Image Optimization — Auto AVIF/WebP.
- Sharp (Node.js) — Server-side image transforms.
- Partytown — Isolate 3rd-party scripts in a Worker.
- Fontaine (Vite plugin) — Auto-generate fallback fonts.
- Critical (Addy Osmani) — Extract critical CSS.
Production Optimization Checklist (2025)
Ordering when optimizing a real site:
- Add RUM — Measure real metrics with Vercel Speed Insights or the web-vitals library
- CDN + HTTP/3 + Brotli — Network layer
- Server TTFB under 200ms — DB query optimization, SSR caching, Edge Functions
- LCP image optimization — AVIF +
fetchpriority="high"+preload - Inline critical CSS, defer the rest —
media="print"hack or the Critical library - Font loading —
font-display: optional+ size-adjust fallback - JS code splitting — Per-route + React.lazy
- Audit 3rd-party scripts — Partytown,
next/scriptstrategy="lazyOnload" - Eliminate CLS — Image/iframe width/height, ad slot aspect-ratio, font fallback matching
- INP optimization — Break up Long Tasks (scheduler.yield), useTransition, Web Worker
- Speculation Rules — Prerender predictable next pages
- Regression prevention — Lighthouse CI, Performance Budget (webpack/rollup plugin)
10 Common Anti-Patterns
loading="lazy"on the LCP image — Permanent LCP delay.- Custom fonts without a Fonts strategy — 3s FOIT, blank screen.
- Full React Hydration + static site — Using Next.js CSR instead of Astro/Next SSG.
- Blocking 3rd-party scripts — Dropping GA/GTM straight into head.
- Client-side Markdown rendering — Should be pre-converted to HTML server-side.
- Monitoring only Lighthouse score — Blind to real UX without RUM.
- Layout thrashing — Reading/writing
offsetHeightinside aforloop. - Loading giant image originals — Using a 4K image for a 200px thumbnail.
- Awaiting a synchronous
fetchin an event handler — Severely degrades INP. - State updates during Hydration — Endless Hydration/re-render loops.
Next Post Preview — The New Wave of Databases — PostgreSQL, pgvector, HNSW, and DB Strategy in the AI Era
The final destination of web performance optimization is usually the database. No matter how good your CDN is, a slow DB query destroys TTFB. The biggest story in databases from 2023–2025 was PostgreSQL's conquest of vector DBs. The pgvector extension is threatening dedicated vector DBs like Pinecone, Weaviate, and Qdrant, ushering in the era of PostgreSQL as an all-purpose DB.
In the next post:
- Why PostgreSQL is #1 again — Top of the 2024 StackOverflow developer survey
- pgvector and HNSW index — The math and practice of vector search
- pgvector vs Pinecone vs Weaviate vs Qdrant — Perf/feature/cost comparison
- PostgreSQL 17 leaps — Logical Replication, Incremental Backup
- Supabase, Neon, PlanetScale, CockroachDB — Cloud PostgreSQL ecosystem
- JSON, JSONB, GIN index — Seamless NoSQL integration
- MVCC principles — The elegance of optimistic concurrency
- Citus, TimescaleDB, PostGIS — The extension ecosystem
- PostgreSQL + AI — RAG pipelines in practice
We'll cover all of the above. In an era where "one DB for everything" has become reality, we'll look at the background and production design. Let's trace why the web performance journey extends into the data layer.