Skip to content

Split View: 웹 성능 최적화 완전 가이드 2025: Core Web Vitals, LCP/INP/CLS, 로딩 전략

✨ Learn with Quiz
|

웹 성능 최적화 완전 가이드 2025: Core Web Vitals, LCP/INP/CLS, 로딩 전략

목차

1. 웹 성능이 중요한 이유

웹 성능은 사용자 경험, 비즈니스 성과, SEO 순위에 직접적으로 영향을 미칩니다. Google 연구에 따르면 페이지 로드 시간이 1초에서 3초로 늘어나면 이탈률이 32% 증가하고, 5초로 늘어나면 90% 증가합니다.

1.1 성능이 비즈니스에 미치는 영향

지표개선 효과
로드 시간 0.1초 단축전환율 8% 증가 (Walmart)
로드 시간 50% 단축매출 12% 증가 (AutoAnything)
로드 시간 2.2초 단축다운로드 15.4% 증가 (Mozilla)
성능 점수 10점 향상이탈률 5-10% 감소

1.2 Google의 페이지 경험 시그널

2021년부터 Google은 Core Web Vitals를 검색 순위 요소에 포함했습니다. 이는 모바일과 데스크톱 검색 모두에 적용됩니다.

페이지 경험 시그널 구성:
├── Core Web Vitals (LCP, INP, CLS)
├── HTTPS 보안
├── 모바일 친화성
├── 침입적 광고 없음
└── Safe Browsing

2. Core Web Vitals 완전 정복

Core Web Vitals는 Google이 정의한 세 가지 핵심 사용자 경험 지표입니다. 2024년 3월부터 INP(Interaction to Next Paint)가 FID를 대체했습니다.

2.1 LCP (Largest Contentful Paint)

LCP는 뷰포트에서 가장 큰 콘텐츠 요소가 렌더링되는 시간을 측정합니다.

임계값:

  • Good: 2.5초 이하
  • Needs Improvement: 2.5초 ~ 4.0초
  • Poor: 4.0초 초과

LCP 대상 요소:

  • img 요소
  • svg 내부의 image 요소
  • video 요소의 poster 이미지
  • CSS background-image가 있는 요소
  • 텍스트 노드를 포함하는 블록 레벨 요소
// LCP 측정 코드
new PerformanceObserver((entryList) => {
  const entries = entryList.getEntries();
  const lastEntry = entries[entries.length - 1];

  console.log('LCP:', lastEntry.startTime);
  console.log('LCP Element:', lastEntry.element);
  console.log('LCP URL:', lastEntry.url);
  console.log('LCP Size:', lastEntry.size);
}).observe({ type: 'largest-contentful-paint', buffered: true });

LCP 최적화 전략:

// 1. 히어로 이미지에 fetchpriority="high" 추가
<img
  src="/hero-image.webp"
  alt="Hero"
  fetchpriority="high"
  width={1200}
  height={600}
/>

// 2. Preload로 LCP 리소스 먼저 로드
<link
  rel="preload"
  as="image"
  href="/hero-image.webp"
  fetchpriority="high"
/>

// 3. Next.js Image 컴포넌트 활용
import Image from 'next/image';

export default function Hero() {
  return (
    <Image
      src="/hero.webp"
      alt="Hero"
      width={1200}
      height={600}
      priority // LCP 이미지에 priority 추가
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,..."
    />
  );
}

2.2 INP (Interaction to Next Paint)

INP는 사용자 상호작용(클릭, 탭, 키 입력)부터 다음 프레임이 렌더링될 때까지의 시간을 측정합니다. FID와 달리 전체 세션의 모든 상호작용을 고려합니다.

임계값:

  • Good: 200ms 이하
  • Needs Improvement: 200ms ~ 500ms
  • Poor: 500ms 초과
// INP 측정
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (entry.interactionId) {
      const duration = entry.duration;
      const inputDelay = entry.processingStart - entry.startTime;
      const processingTime = entry.processingEnd - entry.processingStart;
      const presentationDelay = entry.startTime + entry.duration - entry.processingEnd;

      console.log('INP Breakdown:', {
        duration,
        inputDelay,      // 입력 지연
        processingTime,  // 처리 시간
        presentationDelay // 렌더링 지연
      });
    }
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });

INP 최적화 전략:

// 1. 긴 작업을 yield로 분리
async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);

    // 매 100개마다 메인 스레드에 양보
    if (i % 100 === 0) {
      await scheduler.yield(); // Scheduler API
      // 또는 fallback:
      // await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

// 2. React에서 useTransition으로 우선순위 분리
function SearchResults() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    // 입력은 즉시 반영 (긴급 업데이트)
    setQuery(e.target.value);

    // 검색 결과는 낮은 우선순위 (전환 업데이트)
    startTransition(() => {
      setSearchResults(filterResults(e.target.value));
    });
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending ? <Spinner /> : <ResultsList />}
    </div>
  );
}

// 3. requestIdleCallback으로 비필수 작업 지연
function deferAnalytics(data) {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      sendAnalytics(data);
    }, { timeout: 2000 });
  } else {
    setTimeout(() => sendAnalytics(data), 100);
  }
}

2.3 CLS (Cumulative Layout Shift)

CLS는 페이지 수명 동안 발생하는 예상치 못한 레이아웃 이동의 누적 점수를 측정합니다.

임계값:

  • Good: 0.1 이하
  • Needs Improvement: 0.1 ~ 0.25
  • Poor: 0.25 초과
// CLS 측정
let clsValue = 0;
let clsEntries = [];
let sessionValue = 0;
let sessionEntries = [];

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    // 사용자 입력 후 500ms 이내의 이동은 제외
    if (!entry.hadRecentInput) {
      const firstSessionEntry = sessionEntries[0];
      const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

      // 세션 윈도우: 1초 이내, 5초 한도
      if (
        sessionValue &&
        entry.startTime - lastSessionEntry.startTime < 1000 &&
        entry.startTime - firstSessionEntry.startTime < 5000
      ) {
        sessionValue += entry.value;
        sessionEntries.push(entry);
      } else {
        sessionValue = entry.value;
        sessionEntries = [entry];
      }

      if (sessionValue > clsValue) {
        clsValue = sessionValue;
        clsEntries = [...sessionEntries];
      }
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

CLS 최적화 전략:

/* 1. 이미지/비디오에 항상 크기 지정 */
img, video {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9; /* CSS aspect-ratio 활용 */
}

/* 2. 광고/임베드 영역 사전 확보 */
.ad-slot {
  min-height: 250px;
  contain: layout; /* CSS Containment */
}

/* 3. 폰트 로드 시 레이아웃 이동 방지 */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: optional; /* CLS를 위해 optional 사용 */
  size-adjust: 100.5%; /* 폴백 폰트와 크기 맞춤 */
  ascent-override: 95%;
  descent-override: 22%;
  line-gap-override: 0%;
}

/* 4. 동적 콘텐츠에 contain 속성 */
.dynamic-content {
  contain: layout style;
  content-visibility: auto;
  contain-intrinsic-size: 0 500px;
}

3. 이미지 최적화

이미지는 평균적으로 웹 페이지 바이트의 50% 이상을 차지합니다. 이미지 최적화는 성능 향상의 가장 효과적인 방법입니다.

3.1 차세대 이미지 포맷

포맷별 비교 (동일 품질 기준):
┌─────────┬────────┬─────────┬──────────┬────────────┐
│ 포맷    │ 압축률 │ 투명도  │ 애니메이션│ 브라우저   │
├─────────┼────────┼─────────┼──────────┼────────────┤
JPEG    │ 기준   │ XX100%PNG     │ 낮음   │ OX100%WebP25-34%OO97%+AVIF50%+OO92%+JPEG XL35-60%OO        │ 제한적     │
└─────────┴────────┴─────────┴──────────┴────────────┘
<!-- picture 요소로 포맷 폴백 -->
<picture>
  <source srcset="/image.avif" type="image/avif" />
  <source srcset="/image.webp" type="image/webp" />
  <img src="/image.jpg" alt="Description" width="800" height="600" />
</picture>

3.2 반응형 이미지

<!-- srcset과 sizes로 적절한 크기 제공 -->
<img
  srcset="
    /image-400w.webp  400w,
    /image-800w.webp  800w,
    /image-1200w.webp 1200w,
    /image-1600w.webp 1600w
  "
  sizes="
    (max-width: 640px) 100vw,
    (max-width: 1024px) 50vw,
    33vw
  "
  src="/image-800w.webp"
  alt="Responsive image"
  loading="lazy"
  decoding="async"
  width="800"
  height="600"
/>

3.3 Lazy Loading과 Priority Hints

// Native lazy loading
<img src="/below-fold.webp" loading="lazy" decoding="async" />

// Intersection Observer를 이용한 커스텀 lazy loading
const imageObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.srcset = img.dataset.srcset || '';
        img.classList.add('loaded');
        imageObserver.unobserve(img);
      }
    });
  },
  {
    rootMargin: '200px 0px', // 200px 전에 로드 시작
    threshold: 0.01,
  }
);

document.querySelectorAll('img[data-src]').forEach((img) => {
  imageObserver.observe(img);
});

3.4 Blur Placeholder 구현

// Next.js에서 blur placeholder
import Image from 'next/image';
import { getPlaiceholder } from 'plaiceholder';

export async function getStaticProps() {
  const { base64 } = await getPlaiceholder('/public/hero.jpg');

  return {
    props: { blurDataURL: base64 },
  };
}

export default function Page({ blurDataURL }) {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero"
      width={1200}
      height={600}
      placeholder="blur"
      blurDataURL={blurDataURL}
    />
  );
}

// CSS로 blur 효과 직접 구현
const BlurImage = ({ src, alt }) => {
  const [loaded, setLoaded] = useState(false);

  return (
    <div style={{ position: 'relative', overflow: 'hidden' }}>
      {!loaded && (
        <div
          style={{
            position: 'absolute',
            inset: 0,
            backgroundImage: `url(${src}?w=20&q=10)`,
            backgroundSize: 'cover',
            filter: 'blur(20px)',
            transform: 'scale(1.1)',
          }}
        />
      )}
      <img
        src={src}
        alt={alt}
        onLoad={() => setLoaded(true)}
        style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
      />
    </div>
  );
};

4. JavaScript 최적화

4.1 Tree Shaking

Tree Shaking은 사용하지 않는 코드를 번들에서 제거하는 최적화 기법입니다.

// package.json - sideEffects 설정
{
  "name": "my-library",
  "sideEffects": false,
  // 또는 사이드 이펙트가 있는 파일만 지정
  "sideEffects": ["*.css", "*.scss", "./src/polyfills.js"]
}
// Bad: 전체 라이브러리 임포트 (tree shaking 불가)
import _ from 'lodash';
const result = _.map(data, fn);

// Good: 개별 함수만 임포트
import map from 'lodash/map';
const result = map(data, fn);

// Best: lodash-es 사용 (ES Module)
import { map } from 'lodash-es';
const result = map(data, fn);
// webpack.config.js - Tree Shaking 최적화
module.exports = {
  mode: 'production',
  optimization: {
    usedExports: true,     // 사용된 export만 마킹
    minimize: true,         // 미사용 코드 제거
    sideEffects: true,      // sideEffects 플래그 활용
    concatenateModules: true, // 모듈 연결 (Scope Hoisting)
  },
};

4.2 Code Splitting

// 1. 동적 import로 코드 분할
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <HeavyComponent />
    </Suspense>
  );
}

// 2. 라우트 기반 코드 분할 (Next.js)
import dynamic from 'next/dynamic';

const DashboardChart = dynamic(
  () => import('@/components/DashboardChart'),
  {
    loading: () => <ChartSkeleton />,
    ssr: false, // 클라이언트에서만 로드
  }
);

// 3. webpack magic comments로 청크 제어
const AdminPanel = React.lazy(
  () => import(
    /* webpackChunkName: "admin" */
    /* webpackPrefetch: true */
    './AdminPanel'
  )
);

// 4. Named Export 코드 분할
const MyComponent = React.lazy(() =>
  import('./MyModule').then((module) => ({
    default: module.MyComponent,
  }))
);

4.3 번들 분석

// next.config.js - webpack-bundle-analyzer 설정
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // Next.js config
});

// 실행: ANALYZE=true next build
# 번들 크기 모니터링 도구들
# 1. bundlephobia - 패키지 크기 확인
npx bundlephobia lodash

# 2. source-map-explorer
npx source-map-explorer dist/main.*.js

# 3. webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/stats.json

# 4. size-limit - CI에서 크기 제한
npx size-limit
// .size-limit.json - 번들 크기 제한 설정
[
  {
    "path": "dist/index.js",
    "limit": "50 KB",
    "import": "{ Button }",
    "ignore": ["react", "react-dom"]
  },
  {
    "path": "dist/index.js",
    "limit": "100 KB"
  }
]

5. CSS 최적화

5.1 Critical CSS 추출

// critters 플러그인으로 Critical CSS 인라인
// next.config.js
module.exports = {
  experimental: {
    optimizeCss: true, // Next.js 내장 CSS 최적화
  },
};

// 수동 Critical CSS 추출
const critical = require('critical');

critical.generate({
  inline: true,
  base: 'dist/',
  src: 'index.html',
  target: 'index-critical.html',
  width: 1300,
  height: 900,
  penthouse: {
    blockJSRequests: false,
  },
});

5.2 사용하지 않는 CSS 제거

// PurgeCSS 설정
// postcss.config.js
module.exports = {
  plugins: [
    require('@fullhuman/postcss-purgecss')({
      content: [
        './src/**/*.{js,jsx,ts,tsx}',
        './public/index.html',
      ],
      defaultExtractor: (content) =>
        content.match(/[\w-/:]+(?<!:)/g) || [],
      safelist: {
        standard: [/^modal-/, /^tooltip-/],
        deep: [/^data-theme/],
        greedy: [/animate/],
      },
    }),
  ],
};

5.3 CSS Containment

/* contain 속성으로 렌더링 범위 제한 */
.card {
  contain: layout style paint;
  /* layout: 레이아웃 격리 */
  /* style: 카운터/quotes 격리 */
  /* paint: 페인트 격리 (overflow: hidden 효과) */
}

/* content-visibility로 화면 밖 렌더링 건너뛰기 */
.article-section {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px; /* 예상 크기 힌트 */
}

/* 긴 목록의 각 아이템에 적용 */
.list-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 80px;
}

6. 폰트 최적화

6.1 font-display 전략

/* font-display 옵션 비교 */
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');

  /* swap: FOUT 허용, CLS 발생 가능 */
  font-display: swap;

  /* optional: FOIT 3초 후 시스템 폰트 유지 (CLS 없음) */
  font-display: optional;

  /* fallback: 100ms FOIT 후 swap, 3초 후 유지 */
  font-display: fallback;
}

6.2 폰트 프리로드와 Variable Fonts

<!-- 폰트 프리로드 -->
<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin="anonymous"
/>
/* Variable Font으로 파일 수 줄이기 */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');
  font-weight: 100 900;    /* 가변 weight */
  font-style: normal;
  font-display: optional;
}

/* subset으로 필요한 문자만 포함 */
@font-face {
  font-family: 'NotoSansKR';
  src: url('/fonts/noto-sans-kr-subset.woff2') format('woff2');
  unicode-range: U+AC00-D7A3; /* 한글 완성형만 */
  font-display: swap;
}
// Next.js - next/font 최적화
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'optional',
  preload: true,
  variable: '--font-inter',
  adjustFontFallback: true, // CLS 방지를 위한 폴백 조정
});

export default function RootLayout({ children }) {
  return (
    <html className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

7. 캐싱 전략

7.1 HTTP Cache Headers

캐시 전략 흐름도:
┌─────────────────────────────────────────┐
│ 리소스가 재사용 가능한가?├── NoCache-Control: no-store         │
├── Yes → 매번 서버 확인이 필요한가?│   ├── YesCache-Control: no-cache     │
│   └── No  → 중간 캐시 허용?│       ├── YesCache-Control: public│       └── NoCache-Control: private│            └── max-age 설정             │
│               ├── 해시된 파일 → 31536000│               └── HTML0 + ETag└─────────────────────────────────────────┘
# Nginx 캐시 설정 예시
server {
    # HTML - 항상 서버 확인
    location ~* \.html$ {
        add_header Cache-Control "no-cache";
        add_header ETag $upstream_http_etag;
    }

    # 해시된 정적 에셋 - 1년 캐시
    location ~* \.(js|css|webp|avif|woff2)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # API 응답 - stale-while-revalidate
    location /api/ {
        add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
    }
}

7.2 Service Worker 캐싱

// service-worker.js - Workbox 기반 캐싱 전략
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
  CacheFirst,
  StaleWhileRevalidate,
  NetworkFirst,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// 빌드 시 생성된 에셋 프리캐시
precacheAndRoute(self.__WB_MANIFEST);

// 이미지: Cache First
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30일
      }),
    ],
  })
);

// API: Stale While Revalidate
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({
    cacheName: 'api-cache',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5분
      }),
    ],
  })
);

// HTML 페이지: Network First
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
    networkTimeoutSeconds: 3,
  })
);

7.3 CDN Edge Caching

// Vercel Edge Config 예시
// next.config.js
module.exports = {
  headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'CDN-Cache-Control',
            value: 'public, max-age=60, stale-while-revalidate=3600',
          },
          {
            key: 'Vercel-CDN-Cache-Control',
            value: 'public, max-age=3600, stale-while-revalidate=86400',
          },
        ],
      },
    ];
  },
};

// CloudFront Cache Policy (AWS CDK)
const cachePolicy = new cloudfront.CachePolicy(this, 'CachePolicy', {
  defaultTtl: Duration.hours(1),
  maxTtl: Duration.days(365),
  minTtl: Duration.seconds(0),
  enableAcceptEncodingGzip: true,
  enableAcceptEncodingBrotli: true,
  headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
    'Accept',
    'Accept-Encoding'
  ),
  queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
});

8. 렌더링 패턴 비교

8.1 CSR vs SSR vs SSG vs ISR vs Streaming

렌더링 패턴 비교:
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│          │ TTFBFCPTTISEO├──────────┼──────────┼──────────┼──────────┼──────────┤
CSR      │ 빠름    │ 느림    │ 느림    │ 나쁨    │
SSR      │ 느림    │ 빠름    │ 보통    │ 좋음    │
SSG      │ 매우빠름│ 매우빠름│ 빠름    │ 좋음    │
ISR      │ 매우빠름│ 매우빠름│ 빠름    │ 좋음    │
Streaming│ 빠름    │ 매우빠름│ 빠름    │ 좋음    │
└──────────┴──────────┴──────────┴──────────┴──────────┘

8.2 Streaming SSR (React 18 + Next.js App Router)

// app/dashboard/page.tsx - Streaming SSR
import { Suspense } from 'react';

async function SlowDataComponent() {
  const data = await fetchSlowData(); // 3초 소요
  return <div>{/* data rendering */}</div>;
}

async function FastDataComponent() {
  const data = await fetchFastData(); // 100ms 소요
  return <div>{/* data rendering */}</div>;
}

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* 빠른 컴포넌트는 즉시 렌더링 */}
      <FastDataComponent />

      {/* 느린 컴포넌트는 스트리밍 */}
      <Suspense fallback={<LoadingSkeleton />}>
        <SlowDataComponent />
      </Suspense>
    </div>
  );
}
// app/layout.tsx - loading.tsx 활용
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
      <div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
      <div className="h-4 bg-gray-200 rounded w-1/2" />
    </div>
  );
}

8.3 ISR (Incremental Static Regeneration)

// Next.js App Router - ISR
// app/products/[id]/page.tsx
export const revalidate = 3600; // 1시간마다 재생성

export async function generateStaticParams() {
  const products = await getTopProducts();
  return products.map((product) => ({
    id: product.id.toString(),
  }));
}

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  return <ProductDetail product={product} />;
}

// On-Demand Revalidation
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';

export async function POST(request) {
  const { path, tag, secret } = await request.json();

  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Invalid secret' }, { status: 401 });
  }

  if (tag) {
    revalidateTag(tag);
  } else if (path) {
    revalidatePath(path);
  }

  return Response.json({ revalidated: true, now: Date.now() });
}

9. Prefetching 전략

<!-- DNS Prefetch: 외부 도메인 DNS 미리 해석 -->
<link rel="dns-prefetch" href="//cdn.example.com" />

<!-- Preconnect: DNS + TCP + TLS 미리 연결 -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />

<!-- Prefetch: 다음 네비게이션에 필요한 리소스 미리 가져오기 -->
<link rel="prefetch" href="/next-page.html" />
<link rel="prefetch" href="/api/data.json" as="fetch" />

<!-- Prerender: 전체 페이지 미리 렌더링 -->
<link rel="prerender" href="/likely-next-page" />

9.2 Speculation Rules API

<!-- Speculation Rules API (Chrome 121+) -->
<script type="speculationrules">
{
  "prerender": [
    {
      "where": {
        "and": [
          { "href_matches": "/*" },
          { "not": { "href_matches": "/logout" } },
          { "not": { "href_matches": "/api/*" } }
        ]
      },
      "eagerness": "moderate"
    }
  ],
  "prefetch": [
    {
      "urls": ["/products", "/about"],
      "eagerness": "eager"
    }
  ]
}
</script>
// Next.js에서의 Prefetch 전략
import Link from 'next/link';

// Link 컴포넌트는 자동으로 viewport에 들어오면 prefetch
<Link href="/dashboard" prefetch={true}>
  Dashboard
</Link>

// router.prefetch로 프로그래매틱 prefetch
import { useRouter } from 'next/navigation';

function Navigation() {
  const router = useRouter();

  const handleMouseEnter = () => {
    router.prefetch('/settings');
  };

  return (
    <button onMouseEnter={handleMouseEnter} onClick={() => router.push('/settings')}>
      Settings
    </button>
  );
}

10. 서드파티 스크립트 최적화

10.1 defer/async와 로딩 전략

<!-- 스크립트 로딩 패턴 비교 -->
<!-- 1. 기본: HTML 파싱 차단 -->
<script src="script.js"></script>

<!-- 2. async: 다운로드 병렬, 실행 시 차단 (순서 보장 X) -->
<script async src="analytics.js"></script>

<!-- 3. defer: 다운로드 병렬, DOMContentLoaded 전 순서대로 실행 -->
<script defer src="app.js"></script>

<!-- 4. type=module: defer처럼 동작 + ES Module -->
<script type="module" src="app.mjs"></script>

10.2 Partytown으로 서드파티 격리

// Partytown: 서드파티 스크립트를 Web Worker로 이동
// next.config.js
const { withPartytown } = require('@builder.io/partytown/next');

module.exports = withPartytown({
  partytown: {
    forward: ['dataLayer.push', 'gtag'],
  },
});
<!-- Partytown 적용 -->
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js"></script>
<script type="text/partytown">
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>
// Next.js Script 컴포넌트 활용
import Script from 'next/script';

export default function MyApp({ Component, pageProps }) {
  return (
    <>
      {/* beforeInteractive: _document에서 로드 */}
      <Script
        src="https://polyfill.io/v3/polyfill.min.js"
        strategy="beforeInteractive"
      />

      {/* afterInteractive: 페이지 하이드레이션 후 (기본값) */}
      <Script
        src="https://www.googletagmanager.com/gtag/js"
        strategy="afterInteractive"
      />

      {/* lazyOnload: 브라우저 idle 시 로드 */}
      <Script
        src="https://connect.facebook.net/en_US/fbevents.js"
        strategy="lazyOnload"
      />

      {/* worker: Partytown으로 Web Worker 실행 */}
      <Script
        src="https://example.com/tracking.js"
        strategy="worker"
      />

      <Component {...pageProps} />
    </>
  );
}

11. Lighthouse 심층 분석

11.1 Lighthouse 점수 체계

Lighthouse Performance 점수 가중치 (v12):
┌──────────────────────┬────────┐
│ 지표                 │ 가중치 │
├──────────────────────┼────────┤
FCP (First Content.)10%SI (Speed Index)10%LCP (Largest Cont.)25%TBT (Total Blocking)30%CLS (Cumulative L.S.)25%└──────────────────────┴────────┘
INP는 필드 데이터(CrUX)에서만 측정됨

11.2 Lighthouse CI 자동화

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci && npm run build

      - name: Run Lighthouse
        uses: treosh/lighthouse-ci-action@v11
        with:
          configPath: ./lighthouserc.json
          uploadArtifacts: true
          temporaryPublicStorage: true
// lighthouserc.json
{
  "ci": {
    "collect": {
      "numberOfRuns": 3,
      "url": [
        "http://localhost:3000/",
        "http://localhost:3000/blog",
        "http://localhost:3000/products"
      ],
      "startServerCommand": "npm start"
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["warn", { "minScore": 0.95 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time": ["error", { "maxNumericValue": 300 }]
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}

12. 성능 모니터링

12.1 Web Vitals 라이브러리

// web-vitals 라이브러리로 실제 사용자 지표 수집
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,     // 'good' | 'needs-improvement' | 'poor'
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
    entries: metric.entries,
  });

  // Beacon API로 페이지 이탈 시에도 안정적 전송
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/vitals', body);
  } else {
    fetch('/api/vitals', { body, method: 'POST', keepalive: true });
  }
}

onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

12.2 RUM (Real User Monitoring) 대시보드

// 커스텀 RUM 수집기
class PerformanceMonitor {
  constructor() {
    this.metrics = {};
    this.init();
  }

  init() {
    // Navigation Timing
    window.addEventListener('load', () => {
      const nav = performance.getEntriesByType('navigation')[0];
      this.metrics.dns = nav.domainLookupEnd - nav.domainLookupStart;
      this.metrics.tcp = nav.connectEnd - nav.connectStart;
      this.metrics.ttfb = nav.responseStart - nav.requestStart;
      this.metrics.domLoad = nav.domContentLoadedEventEnd - nav.fetchStart;
      this.metrics.fullLoad = nav.loadEventEnd - nav.fetchStart;

      this.report();
    });

    // Resource Timing
    const resourceObserver = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.transferSize === 0) continue; // 캐시에서 로드된 리소스 제외

        this.trackResource({
          name: entry.name,
          type: entry.initiatorType,
          duration: entry.duration,
          size: entry.transferSize,
        });
      }
    });
    resourceObserver.observe({ type: 'resource', buffered: true });

    // Long Tasks
    const longTaskObserver = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        this.trackLongTask({
          duration: entry.duration,
          startTime: entry.startTime,
          attribution: entry.attribution,
        });
      }
    });

    try {
      longTaskObserver.observe({ type: 'longtask', buffered: true });
    } catch (e) {
      // longtask observer not supported
    }
  }

  trackResource(data) {
    // 느린 리소스 경고
    if (data.duration > 1000) {
      console.warn('Slow resource:', data.name, data.duration + 'ms');
    }
  }

  trackLongTask(data) {
    // 50ms 이상의 Long Task 기록
    console.warn('Long Task:', data.duration + 'ms');
  }

  report() {
    console.table(this.metrics);
  }
}

new PerformanceMonitor();

12.3 CrUX (Chrome User Experience Report)

// CrUX API로 필드 데이터 조회
async function getCruxData(url) {
  const apiKey = process.env.CRUX_API_KEY;
  const response = await fetch(
    'https://chromeuxreport.googleapis.com/v1/records:queryRecord' +
    '?key=' + apiKey,
    {
      method: 'POST',
      body: JSON.stringify({
        url: url,
        formFactor: 'PHONE',
        metrics: [
          'largest_contentful_paint',
          'interaction_to_next_paint',
          'cumulative_layout_shift',
          'experimental_time_to_first_byte',
        ],
      }),
    }
  );

  const data = await response.json();

  // p75 값 추출
  const lcp = data.record.metrics.largest_contentful_paint;
  console.log('LCP p75:', lcp.percentiles.p75 + 'ms');
  console.log('LCP distribution:', lcp.histogram);

  return data;
}

13. 실전 최적화 체크리스트

성능 최적화 체크리스트:
[ ] 이미지
    [ ] WebP/AVIF 포맷 사용
    [ ] 적절한 크기의 반응형 이미지 제공
    [ ] LCP 이미지에 priority/fetchpriority 설정
    [ ] 스크롤 아래 이미지에 loading="lazy"
    [ ] aspect-ratio 또는 width/height 명시

[ ] JavaScript
    [ ] 코드 분할 (route-based + component-based)
    [ ] Tree Shaking 확인 (sideEffects: false)
    [ ] 번들 크기 모니터링
    [ ] 불필요한 polyfill 제거
    [ ] 서드파티 스크립트 defer/async/worker

[ ] CSS
    [ ] Critical CSS 인라인
    [ ] 미사용 CSS 제거
    [ ] content-visibility 활용
    [ ] CSS Containment 적용

[ ] 폰트
    [ ] WOFF2 포맷 사용
    [ ] font-display: optional 또는 swap
    [ ] 서브셋 적용 (한글/라틴 분리)
    [ ] Variable Font 활용
    [ ] preload 설정

[ ] 캐싱
    [ ] 정적 에셋: immutable + 1    [ ] HTML: no-cache + ETag
    [ ] API: stale-while-revalidate
    [ ] Service Worker 캐싱 전략
    [ ] CDN Edge 캐싱 설정

[ ] 렌더링
    [ ] 적절한 렌더링 패턴 선택
    [ ] Suspense로 스트리밍
    [ ] ISR revalidate 설정
    [ ] loading.tsx 스켈레톤

[ ] 모니터링
    [ ] Lighthouse CI 자동화
    [ ] Web Vitals RUM 수집
    [ ] CrUX 데이터 모니터링
    [ ] 성능 회귀 알림 설정

14. 퀴즈

아래 퀴즈로 학습 내용을 점검해 보세요.

Q1. LCP(Largest Contentful Paint)의 Good 기준값과 이를 개선하기 위한 가장 효과적인 방법 두 가지는?

A1. LCP의 Good 기준값은 2.5초 이하입니다.

가장 효과적인 개선 방법:

  1. LCP 이미지에 fetchpriority="high" 설정 - 브라우저가 LCP 리소스를 최우선으로 다운로드하게 합니다.
  2. preload 링크 태그 사용 - 파서가 발견하기 전에 LCP 리소스 다운로드를 시작합니다.

추가로 서버 응답 시간 단축(CDN, 캐싱), 렌더링 차단 리소스 제거, 이미지 최적화(WebP/AVIF) 등이 효과적입니다.

Q2. INP와 이전 지표인 FID의 차이점은 무엇이며, INP를 개선하기 위한 React 패턴은?

A2. FID는 첫 번째 상호작용의 입력 지연만 측정했지만, INP는 전체 세션의 모든 상호작용을 고려하여 가장 느린 상호작용(또는 p98)을 보고합니다. 또한 INP는 입력 지연뿐 아니라 처리 시간과 렌더링 지연까지 포함합니다.

React에서의 개선 패턴:

  • useTransition: 긴급하지 않은 상태 업데이트를 낮은 우선순위로 처리
  • useDeferredValue: 값의 업데이트를 지연시켜 UI 응답성 유지
  • scheduler.yield(): 긴 작업을 분할하여 메인 스레드에 양보
Q3. Service Worker에서 CacheFirst와 StaleWhileRevalidate 전략의 차이점과 적합한 사용 사례는?

A3.

CacheFirst: 캐시에 있으면 캐시에서 즉시 반환, 없으면 네트워크 요청. 캐시 히트 시 네트워크 요청을 하지 않습니다.

  • 적합한 사례: 이미지, 폰트, 정적 에셋 등 자주 변경되지 않는 리소스

StaleWhileRevalidate: 캐시에서 즉시 반환하면서 동시에 백그라운드에서 네트워크 요청으로 캐시를 갱신합니다.

  • 적합한 사례: API 응답, 뉴스 피드 등 최신성은 필요하지만 즉각적인 응답도 중요한 리소스

NetworkFirst는 HTML 페이지처럼 항상 최신 콘텐츠가 필요한 경우에 적합합니다.

Q4. Streaming SSR이 기존 SSR보다 나은 점과 Next.js App Router에서의 구현 방법은?

A4. 기존 SSR은 모든 데이터 fetching이 완료될 때까지 HTML 전송을 시작하지 못했습니다. Streaming SSR은 준비된 부분부터 HTML 청크를 즉시 전송하여:

  • TTFB를 단축합니다 (느린 데이터에 의존하지 않음)
  • FCP를 개선합니다 (빠른 컴포넌트 먼저 표시)
  • 사용자 체감 성능이 향상됩니다

Next.js App Router에서의 구현:

  1. Suspense 컴포넌트로 느린 데이터 컴포넌트를 감쌈
  2. fallback prop에 스켈레톤/로딩 UI 제공
  3. loading.tsx 파일로 라우트 레벨 로딩 상태 정의
  4. 서버 컴포넌트에서 async/await으로 데이터 페칭
Q5. Speculation Rules API와 기존 link rel="prefetch"의 차이점은 무엇이며, 어떤 장점이 있나?

A5. 기존 link rel="prefetch"는 리소스를 미리 다운로드만 하지만, Speculation Rules API는 전체 페이지를 미리 렌더링(prerender)할 수 있습니다.

주요 차이점:

  • Prefetch: 리소스 다운로드만 (HTML, JS 등)
  • Prerender (Speculation Rules): 전체 페이지를 숨겨진 탭에서 렌더링까지 완료

장점:

  1. 즉각적인 페이지 전환 (이미 렌더링 완료)
  2. 조건부 규칙: URL 패턴, eagerness 레벨 설정 가능
  3. 브라우저 최적화: 메모리/네트워크 상황에 따라 자동 조절
  4. JSON 기반 선언적 문법으로 유지보수 용이

단, Chrome 121 이상에서만 지원되며 로그아웃이나 API 엔드포인트는 제외해야 합니다.

15. 참고 자료

  1. web.dev - Core Web Vitals - Google의 Core Web Vitals 공식 가이드
  2. web.dev - Optimize LCP - LCP 최적화 가이드
  3. web.dev - Optimize INP - INP 최적화 가이드
  4. web.dev - Optimize CLS - CLS 최적화 가이드
  5. Chrome Developers - Speculation Rules API - Speculation Rules 가이드
  6. Next.js Documentation - Optimizing - Next.js 최적화 문서
  7. Workbox - Service Worker Libraries - Google Workbox 공식 문서
  8. web.dev - Optimize Images - 이미지 최적화 가이드
  9. Partytown - Web Worker for Third-party Scripts - Partytown 공식 사이트
  10. web-vitals - JavaScript Library - web-vitals 라이브러리
  11. CrUX Dashboard - Chrome UX Report 문서
  12. Lighthouse CI - Lighthouse CI 자동화
  13. HTTP Caching - MDN - HTTP 캐싱 완전 가이드

Web Performance Optimization Complete Guide 2025: Core Web Vitals, LCP/INP/CLS, Loading Strategies

Table of Contents

1. Why Web Performance Matters

Web performance directly impacts user experience, business outcomes, and SEO rankings. According to Google research, when page load time increases from 1 second to 3 seconds, bounce rate increases by 32%, and at 5 seconds, it increases by 90%.

1.1 Performance Impact on Business

MetricImprovement Effect
0.1s load time reduction8% conversion increase (Walmart)
50% load time reduction12% revenue increase (AutoAnything)
2.2s load time reduction15.4% download increase (Mozilla)
10-point performance score increase5-10% bounce rate decrease

1.2 Google Page Experience Signals

Since 2021, Google has included Core Web Vitals as a search ranking factor. This applies to both mobile and desktop searches.

Page Experience Signal Components:
├── Core Web Vitals (LCP, INP, CLS)
├── HTTPS Security
├── Mobile Friendliness
├── No Intrusive Interstitials
└── Safe Browsing

2. Mastering Core Web Vitals

Core Web Vitals are three key user experience metrics defined by Google. Since March 2024, INP (Interaction to Next Paint) replaced FID.

2.1 LCP (Largest Contentful Paint)

LCP measures the time for the largest content element in the viewport to render.

Thresholds:

  • Good: 2.5 seconds or less
  • Needs Improvement: 2.5s to 4.0s
  • Poor: Over 4.0 seconds

LCP Target Elements:

  • img elements
  • image elements inside svg
  • Poster images from video elements
  • Elements with CSS background-image
  • Block-level elements containing text nodes
// LCP Measurement Code
new PerformanceObserver((entryList) => {
  const entries = entryList.getEntries();
  const lastEntry = entries[entries.length - 1];

  console.log('LCP:', lastEntry.startTime);
  console.log('LCP Element:', lastEntry.element);
  console.log('LCP URL:', lastEntry.url);
  console.log('LCP Size:', lastEntry.size);
}).observe({ type: 'largest-contentful-paint', buffered: true });

LCP Optimization Strategies:

// 1. Add fetchpriority="high" to hero image
<img
  src="/hero-image.webp"
  alt="Hero"
  fetchpriority="high"
  width={1200}
  height={600}
/>

// 2. Preload LCP resources
<link
  rel="preload"
  as="image"
  href="/hero-image.webp"
  fetchpriority="high"
/>

// 3. Use Next.js Image component
import Image from 'next/image';

export default function Hero() {
  return (
    <Image
      src="/hero.webp"
      alt="Hero"
      width={1200}
      height={600}
      priority // Add priority for LCP images
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,..."
    />
  );
}

2.2 INP (Interaction to Next Paint)

INP measures the time from user interaction (clicks, taps, key presses) until the next frame is rendered. Unlike FID, it considers all interactions throughout the entire session.

Thresholds:

  • Good: 200ms or less
  • Needs Improvement: 200ms to 500ms
  • Poor: Over 500ms
// INP Measurement
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (entry.interactionId) {
      const duration = entry.duration;
      const inputDelay = entry.processingStart - entry.startTime;
      const processingTime = entry.processingEnd - entry.processingStart;
      const presentationDelay = entry.startTime + entry.duration - entry.processingEnd;

      console.log('INP Breakdown:', {
        duration,
        inputDelay,        // Input delay
        processingTime,    // Processing time
        presentationDelay  // Presentation delay
      });
    }
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });

INP Optimization Strategies:

// 1. Break long tasks with yield
async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);

    // Yield to main thread every 100 items
    if (i % 100 === 0) {
      await scheduler.yield(); // Scheduler API
      // Or fallback:
      // await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

// 2. Use useTransition in React for priority separation
function SearchResults() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    // Input updates immediately (urgent update)
    setQuery(e.target.value);

    // Search results at lower priority (transition update)
    startTransition(() => {
      setSearchResults(filterResults(e.target.value));
    });
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending ? <Spinner /> : <ResultsList />}
    </div>
  );
}

// 3. Defer non-essential work with requestIdleCallback
function deferAnalytics(data) {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      sendAnalytics(data);
    }, { timeout: 2000 });
  } else {
    setTimeout(() => sendAnalytics(data), 100);
  }
}

2.3 CLS (Cumulative Layout Shift)

CLS measures the cumulative score of unexpected layout shifts during the page's lifespan.

Thresholds:

  • Good: 0.1 or less
  • Needs Improvement: 0.1 to 0.25
  • Poor: Over 0.25
// CLS Measurement
let clsValue = 0;
let clsEntries = [];
let sessionValue = 0;
let sessionEntries = [];

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    // Exclude shifts within 500ms of user input
    if (!entry.hadRecentInput) {
      const firstSessionEntry = sessionEntries[0];
      const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

      // Session window: within 1 second, 5 second limit
      if (
        sessionValue &&
        entry.startTime - lastSessionEntry.startTime < 1000 &&
        entry.startTime - firstSessionEntry.startTime < 5000
      ) {
        sessionValue += entry.value;
        sessionEntries.push(entry);
      } else {
        sessionValue = entry.value;
        sessionEntries = [entry];
      }

      if (sessionValue > clsValue) {
        clsValue = sessionValue;
        clsEntries = [...sessionEntries];
      }
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

CLS Optimization Strategies:

/* 1. Always specify dimensions for images/videos */
img, video {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9; /* Use CSS aspect-ratio */
}

/* 2. Reserve space for ads/embeds */
.ad-slot {
  min-height: 250px;
  contain: layout; /* CSS Containment */
}

/* 3. Prevent layout shift during font loading */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: optional; /* Use optional for CLS */
  size-adjust: 100.5%; /* Match fallback font size */
  ascent-override: 95%;
  descent-override: 22%;
  line-gap-override: 0%;
}

/* 4. Use contain for dynamic content */
.dynamic-content {
  contain: layout style;
  content-visibility: auto;
  contain-intrinsic-size: 0 500px;
}

3. Image Optimization

Images account for over 50% of average web page bytes. Image optimization is the most effective method for performance improvement.

3.1 Next-Generation Image Formats

Format Comparison (Same Quality Basis):
┌─────────┬─────────────┬──────────────┬───────────┬─────────────┐
FormatCompressionTransparencyAnimationBrowser├─────────┼─────────────┼──────────────┼───────────┼─────────────┤
JPEGBaselineNoNo100%PNGLowYesNo100%WebP25-34%YesYes97%+AVIF50%+YesYes92%+JPEG XL35-60%YesYesLimited└─────────┴─────────────┴──────────────┴───────────┴─────────────┘
<!-- picture element for format fallback -->
<picture>
  <source srcset="/image.avif" type="image/avif" />
  <source srcset="/image.webp" type="image/webp" />
  <img src="/image.jpg" alt="Description" width="800" height="600" />
</picture>

3.2 Responsive Images

<!-- Serve appropriate sizes with srcset and sizes -->
<img
  srcset="
    /image-400w.webp  400w,
    /image-800w.webp  800w,
    /image-1200w.webp 1200w,
    /image-1600w.webp 1600w
  "
  sizes="
    (max-width: 640px) 100vw,
    (max-width: 1024px) 50vw,
    33vw
  "
  src="/image-800w.webp"
  alt="Responsive image"
  loading="lazy"
  decoding="async"
  width="800"
  height="600"
/>

3.3 Lazy Loading and Priority Hints

// Native lazy loading
<img src="/below-fold.webp" loading="lazy" decoding="async" />

// Custom lazy loading with Intersection Observer
const imageObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.srcset = img.dataset.srcset || '';
        img.classList.add('loaded');
        imageObserver.unobserve(img);
      }
    });
  },
  {
    rootMargin: '200px 0px', // Start loading 200px before
    threshold: 0.01,
  }
);

document.querySelectorAll('img[data-src]').forEach((img) => {
  imageObserver.observe(img);
});

3.4 Blur Placeholder Implementation

// Blur placeholder in Next.js
import Image from 'next/image';
import { getPlaiceholder } from 'plaiceholder';

export async function getStaticProps() {
  const { base64 } = await getPlaiceholder('/public/hero.jpg');

  return {
    props: { blurDataURL: base64 },
  };
}

export default function Page({ blurDataURL }) {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero"
      width={1200}
      height={600}
      placeholder="blur"
      blurDataURL={blurDataURL}
    />
  );
}

// Implementing blur effect with CSS directly
const BlurImage = ({ src, alt }) => {
  const [loaded, setLoaded] = useState(false);

  return (
    <div style={{ position: 'relative', overflow: 'hidden' }}>
      {!loaded && (
        <div
          style={{
            position: 'absolute',
            inset: 0,
            backgroundImage: `url(${src}?w=20&q=10)`,
            backgroundSize: 'cover',
            filter: 'blur(20px)',
            transform: 'scale(1.1)',
          }}
        />
      )}
      <img
        src={src}
        alt={alt}
        onLoad={() => setLoaded(true)}
        style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
      />
    </div>
  );
};

4. JavaScript Optimization

4.1 Tree Shaking

Tree Shaking is an optimization technique that removes unused code from bundles.

// package.json - sideEffects configuration
{
  "name": "my-library",
  "sideEffects": false,
  // Or specify only files with side effects
  "sideEffects": ["*.css", "*.scss", "./src/polyfills.js"]
}
// Bad: Import entire library (tree shaking impossible)
import _ from 'lodash';
const result = _.map(data, fn);

// Good: Import individual functions
import map from 'lodash/map';
const result = map(data, fn);

// Best: Use lodash-es (ES Module)
import { map } from 'lodash-es';
const result = map(data, fn);
// webpack.config.js - Tree Shaking optimization
module.exports = {
  mode: 'production',
  optimization: {
    usedExports: true,        // Mark used exports
    minimize: true,            // Remove unused code
    sideEffects: true,         // Use sideEffects flag
    concatenateModules: true,  // Module concatenation (Scope Hoisting)
  },
};

4.2 Code Splitting

// 1. Code splitting with dynamic import
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <HeavyComponent />
    </Suspense>
  );
}

// 2. Route-based code splitting (Next.js)
import dynamic from 'next/dynamic';

const DashboardChart = dynamic(
  () => import('@/components/DashboardChart'),
  {
    loading: () => <ChartSkeleton />,
    ssr: false, // Client-side only
  }
);

// 3. Control chunks with webpack magic comments
const AdminPanel = React.lazy(
  () => import(
    /* webpackChunkName: "admin" */
    /* webpackPrefetch: true */
    './AdminPanel'
  )
);

// 4. Code splitting named exports
const MyComponent = React.lazy(() =>
  import('./MyModule').then((module) => ({
    default: module.MyComponent,
  }))
);

4.3 Bundle Analysis

// next.config.js - webpack-bundle-analyzer setup
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // Next.js config
});

// Run: ANALYZE=true next build
# Bundle size monitoring tools
# 1. bundlephobia - Check package size
npx bundlephobia lodash

# 2. source-map-explorer
npx source-map-explorer dist/main.*.js

# 3. webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/stats.json

# 4. size-limit - Size limits in CI
npx size-limit
// .size-limit.json - Bundle size limit configuration
[
  {
    "path": "dist/index.js",
    "limit": "50 KB",
    "import": "{ Button }",
    "ignore": ["react", "react-dom"]
  },
  {
    "path": "dist/index.js",
    "limit": "100 KB"
  }
]

5. CSS Optimization

5.1 Critical CSS Extraction

// Inline Critical CSS with critters plugin
// next.config.js
module.exports = {
  experimental: {
    optimizeCss: true, // Next.js built-in CSS optimization
  },
};

// Manual Critical CSS extraction
const critical = require('critical');

critical.generate({
  inline: true,
  base: 'dist/',
  src: 'index.html',
  target: 'index-critical.html',
  width: 1300,
  height: 900,
  penthouse: {
    blockJSRequests: false,
  },
});

5.2 Removing Unused CSS

// PurgeCSS configuration
// postcss.config.js
module.exports = {
  plugins: [
    require('@fullhuman/postcss-purgecss')({
      content: [
        './src/**/*.{js,jsx,ts,tsx}',
        './public/index.html',
      ],
      defaultExtractor: (content) =>
        content.match(/[\w-/:]+(?<!:)/g) || [],
      safelist: {
        standard: [/^modal-/, /^tooltip-/],
        deep: [/^data-theme/],
        greedy: [/animate/],
      },
    }),
  ],
};

5.3 CSS Containment

/* Limit rendering scope with contain property */
.card {
  contain: layout style paint;
  /* layout: Layout isolation */
  /* style: Counter/quotes isolation */
  /* paint: Paint isolation (overflow: hidden effect) */
}

/* Skip off-screen rendering with content-visibility */
.article-section {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px; /* Estimated size hint */
}

/* Apply to each item in long lists */
.list-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 80px;
}

6. Font Optimization

6.1 font-display Strategy

/* font-display option comparison */
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');

  /* swap: Allows FOUT, may cause CLS */
  font-display: swap;

  /* optional: 3s FOIT then keeps system font (no CLS) */
  font-display: optional;

  /* fallback: 100ms FOIT then swap, keeps after 3s */
  font-display: fallback;
}

6.2 Font Preloading and Variable Fonts

<!-- Font preloading -->
<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin="anonymous"
/>
/* Reduce file count with Variable Fonts */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');
  font-weight: 100 900;    /* Variable weight */
  font-style: normal;
  font-display: optional;
}

/* Include only needed characters with subsetting */
@font-face {
  font-family: 'NotoSansKR';
  src: url('/fonts/noto-sans-kr-subset.woff2') format('woff2');
  unicode-range: U+AC00-D7A3; /* Korean syllables only */
  font-display: swap;
}
// Next.js - next/font optimization
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'optional',
  preload: true,
  variable: '--font-inter',
  adjustFontFallback: true, // Adjust fallback for CLS prevention
});

export default function RootLayout({ children }) {
  return (
    <html className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

7. Caching Strategies

7.1 HTTP Cache Headers

Cache Strategy Flowchart:
┌───────────────────────────────────────────┐
Is the resource reusable?├── No  -> Cache-Control: no-store          │
├── Yes -> Need server validation every time?│   ├── Yes -> Cache-Control: no-cache      │
│   └── No  -> Allow intermediate caches?│       ├── Yes -> Cache-Control: public│       └── No  -> Cache-Control: private│            └── Set max-age                │
│               ├── Hashed files -> 31536000│               └── HTML -> 0 + ETag└───────────────────────────────────────────┘
# Nginx cache configuration example
server {
    # HTML - Always validate with server
    location ~* \.html$ {
        add_header Cache-Control "no-cache";
        add_header ETag $upstream_http_etag;
    }

    # Hashed static assets - 1 year cache
    location ~* \.(js|css|webp|avif|woff2)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # API responses - stale-while-revalidate
    location /api/ {
        add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
    }
}

7.2 Service Worker Caching

// service-worker.js - Workbox-based caching strategies
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
  CacheFirst,
  StaleWhileRevalidate,
  NetworkFirst,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// Precache build-time assets
precacheAndRoute(self.__WB_MANIFEST);

// Images: Cache First
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  })
);

// API: Stale While Revalidate
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({
    cacheName: 'api-cache',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5 minutes
      }),
    ],
  })
);

// HTML Pages: Network First
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
    networkTimeoutSeconds: 3,
  })
);

7.3 CDN Edge Caching

// Vercel Edge Config Example
// next.config.js
module.exports = {
  headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'CDN-Cache-Control',
            value: 'public, max-age=60, stale-while-revalidate=3600',
          },
          {
            key: 'Vercel-CDN-Cache-Control',
            value: 'public, max-age=3600, stale-while-revalidate=86400',
          },
        ],
      },
    ];
  },
};

// CloudFront Cache Policy (AWS CDK)
const cachePolicy = new cloudfront.CachePolicy(this, 'CachePolicy', {
  defaultTtl: Duration.hours(1),
  maxTtl: Duration.days(365),
  minTtl: Duration.seconds(0),
  enableAcceptEncodingGzip: true,
  enableAcceptEncodingBrotli: true,
  headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
    'Accept',
    'Accept-Encoding'
  ),
  queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
});

8. Rendering Pattern Comparison

8.1 CSR vs SSR vs SSG vs ISR vs Streaming

Rendering Pattern Comparison:
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│          │ TTFBFCPTTISEO├──────────┼──────────┼──────────┼──────────┼──────────┤
CSRFastSlowSlowPoorSSRSlowFastMediumGoodSSGV. FastV. FastFastGoodISRV. FastV. FastFastGoodStreaming│ FastV. FastFastGood└──────────┴──────────┴──────────┴──────────┴──────────┘

8.2 Streaming SSR (React 18 + Next.js App Router)

// app/dashboard/page.tsx - Streaming SSR
import { Suspense } from 'react';

async function SlowDataComponent() {
  const data = await fetchSlowData(); // Takes 3 seconds
  return <div>{/* data rendering */}</div>;
}

async function FastDataComponent() {
  const data = await fetchFastData(); // Takes 100ms
  return <div>{/* data rendering */}</div>;
}

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* Fast components render immediately */}
      <FastDataComponent />

      {/* Slow components are streamed */}
      <Suspense fallback={<LoadingSkeleton />}>
        <SlowDataComponent />
      </Suspense>
    </div>
  );
}
// app/layout.tsx - Using loading.tsx
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
      <div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
      <div className="h-4 bg-gray-200 rounded w-1/2" />
    </div>
  );
}

8.3 ISR (Incremental Static Regeneration)

// Next.js App Router - ISR
// app/products/[id]/page.tsx
export const revalidate = 3600; // Regenerate every hour

export async function generateStaticParams() {
  const products = await getTopProducts();
  return products.map((product) => ({
    id: product.id.toString(),
  }));
}

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  return <ProductDetail product={product} />;
}

// On-Demand Revalidation
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';

export async function POST(request) {
  const { path, tag, secret } = await request.json();

  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Invalid secret' }, { status: 401 });
  }

  if (tag) {
    revalidateTag(tag);
  } else if (path) {
    revalidatePath(path);
  }

  return Response.json({ revalidated: true, now: Date.now() });
}

9. Prefetching Strategies

<!-- DNS Prefetch: Resolve external domain DNS early -->
<link rel="dns-prefetch" href="//cdn.example.com" />

<!-- Preconnect: DNS + TCP + TLS connection early -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />

<!-- Prefetch: Pre-fetch resources for next navigation -->
<link rel="prefetch" href="/next-page.html" />
<link rel="prefetch" href="/api/data.json" as="fetch" />

<!-- Prerender: Pre-render the entire page -->
<link rel="prerender" href="/likely-next-page" />

9.2 Speculation Rules API

<!-- Speculation Rules API (Chrome 121+) -->
<script type="speculationrules">
{
  "prerender": [
    {
      "where": {
        "and": [
          { "href_matches": "/*" },
          { "not": { "href_matches": "/logout" } },
          { "not": { "href_matches": "/api/*" } }
        ]
      },
      "eagerness": "moderate"
    }
  ],
  "prefetch": [
    {
      "urls": ["/products", "/about"],
      "eagerness": "eager"
    }
  ]
}
</script>
// Prefetch strategies in Next.js
import Link from 'next/link';

// Link component automatically prefetches when entering viewport
<Link href="/dashboard" prefetch={true}>
  Dashboard
</Link>

// Programmatic prefetch with router.prefetch
import { useRouter } from 'next/navigation';

function Navigation() {
  const router = useRouter();

  const handleMouseEnter = () => {
    router.prefetch('/settings');
  };

  return (
    <button onMouseEnter={handleMouseEnter} onClick={() => router.push('/settings')}>
      Settings
    </button>
  );
}

10. Third-Party Script Optimization

10.1 defer/async and Loading Strategies

<!-- Script loading pattern comparison -->
<!-- 1. Default: Blocks HTML parsing -->
<script src="script.js"></script>

<!-- 2. async: Download parallel, blocks on execution (no order guarantee) -->
<script async src="analytics.js"></script>

<!-- 3. defer: Download parallel, executes in order before DOMContentLoaded -->
<script defer src="app.js"></script>

<!-- 4. type=module: Behaves like defer + ES Module -->
<script type="module" src="app.mjs"></script>

10.2 Isolating Third-Party Scripts with Partytown

// Partytown: Move third-party scripts to Web Worker
// next.config.js
const { withPartytown } = require('@builder.io/partytown/next');

module.exports = withPartytown({
  partytown: {
    forward: ['dataLayer.push', 'gtag'],
  },
});
<!-- Partytown applied -->
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js"></script>
<script type="text/partytown">
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>
// Next.js Script component usage
import Script from 'next/script';

export default function MyApp({ Component, pageProps }) {
  return (
    <>
      {/* beforeInteractive: Loads in _document */}
      <Script
        src="https://polyfill.io/v3/polyfill.min.js"
        strategy="beforeInteractive"
      />

      {/* afterInteractive: After page hydration (default) */}
      <Script
        src="https://www.googletagmanager.com/gtag/js"
        strategy="afterInteractive"
      />

      {/* lazyOnload: Load during browser idle */}
      <Script
        src="https://connect.facebook.net/en_US/fbevents.js"
        strategy="lazyOnload"
      />

      {/* worker: Execute in Web Worker via Partytown */}
      <Script
        src="https://example.com/tracking.js"
        strategy="worker"
      />

      <Component {...pageProps} />
    </>
  );
}

11. Lighthouse Deep Dive

11.1 Lighthouse Scoring System

Lighthouse Performance Score Weights (v12):
┌───────────────────────────┬────────┐
MetricWeight├───────────────────────────┼────────┤
FCP (First Contentful)10%SI (Speed Index)10%LCP (Largest Contentful)25%TBT (Total Blocking Time)30%CLS (Cumulative L. Shift)25%└───────────────────────────┴────────┘
INP is measured only in field data (CrUX)

11.2 Lighthouse CI Automation

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci && npm run build

      - name: Run Lighthouse
        uses: treosh/lighthouse-ci-action@v11
        with:
          configPath: ./lighthouserc.json
          uploadArtifacts: true
          temporaryPublicStorage: true
// lighthouserc.json
{
  "ci": {
    "collect": {
      "numberOfRuns": 3,
      "url": [
        "http://localhost:3000/",
        "http://localhost:3000/blog",
        "http://localhost:3000/products"
      ],
      "startServerCommand": "npm start"
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["warn", { "minScore": 0.95 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time": ["error", { "maxNumericValue": 300 }]
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}

12. Performance Monitoring

12.1 Web Vitals Library

// Collect real user metrics with web-vitals library
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,     // 'good' | 'needs-improvement' | 'poor'
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
    entries: metric.entries,
  });

  // Beacon API for reliable transmission even on page unload
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/vitals', body);
  } else {
    fetch('/api/vitals', { body, method: 'POST', keepalive: true });
  }
}

onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

12.2 RUM (Real User Monitoring) Dashboard

// Custom RUM collector
class PerformanceMonitor {
  constructor() {
    this.metrics = {};
    this.init();
  }

  init() {
    // Navigation Timing
    window.addEventListener('load', () => {
      const nav = performance.getEntriesByType('navigation')[0];
      this.metrics.dns = nav.domainLookupEnd - nav.domainLookupStart;
      this.metrics.tcp = nav.connectEnd - nav.connectStart;
      this.metrics.ttfb = nav.responseStart - nav.requestStart;
      this.metrics.domLoad = nav.domContentLoadedEventEnd - nav.fetchStart;
      this.metrics.fullLoad = nav.loadEventEnd - nav.fetchStart;

      this.report();
    });

    // Resource Timing
    const resourceObserver = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.transferSize === 0) continue; // Skip cached resources

        this.trackResource({
          name: entry.name,
          type: entry.initiatorType,
          duration: entry.duration,
          size: entry.transferSize,
        });
      }
    });
    resourceObserver.observe({ type: 'resource', buffered: true });

    // Long Tasks
    const longTaskObserver = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        this.trackLongTask({
          duration: entry.duration,
          startTime: entry.startTime,
          attribution: entry.attribution,
        });
      }
    });

    try {
      longTaskObserver.observe({ type: 'longtask', buffered: true });
    } catch (e) {
      // longtask observer not supported
    }
  }

  trackResource(data) {
    // Alert for slow resources
    if (data.duration > 1000) {
      console.warn('Slow resource:', data.name, data.duration + 'ms');
    }
  }

  trackLongTask(data) {
    // Record Long Tasks over 50ms
    console.warn('Long Task:', data.duration + 'ms');
  }

  report() {
    console.table(this.metrics);
  }
}

new PerformanceMonitor();

12.3 CrUX (Chrome User Experience Report)

// Query field data with CrUX API
async function getCruxData(url) {
  const apiKey = process.env.CRUX_API_KEY;
  const response = await fetch(
    'https://chromeuxreport.googleapis.com/v1/records:queryRecord' +
    '?key=' + apiKey,
    {
      method: 'POST',
      body: JSON.stringify({
        url: url,
        formFactor: 'PHONE',
        metrics: [
          'largest_contentful_paint',
          'interaction_to_next_paint',
          'cumulative_layout_shift',
          'experimental_time_to_first_byte',
        ],
      }),
    }
  );

  const data = await response.json();

  // Extract p75 values
  const lcp = data.record.metrics.largest_contentful_paint;
  console.log('LCP p75:', lcp.percentiles.p75 + 'ms');
  console.log('LCP distribution:', lcp.histogram);

  return data;
}

13. Practical Optimization Checklist

Performance Optimization Checklist:
[ ] Images
    [ ] Use WebP/AVIF formats
    [ ] Serve appropriately sized responsive images
    [ ] Set priority/fetchpriority for LCP images
    [ ] Add loading="lazy" for below-fold images
    [ ] Specify aspect-ratio or width/height

[ ] JavaScript
    [ ] Code splitting (route-based + component-based)
    [ ] Verify Tree Shaking (sideEffects: false)
    [ ] Monitor bundle size
    [ ] Remove unnecessary polyfills
    [ ] defer/async/worker for third-party scripts

[ ] CSS
    [ ] Inline Critical CSS
    [ ] Remove unused CSS
    [ ] Use content-visibility
    [ ] Apply CSS Containment

[ ] Fonts
    [ ] Use WOFF2 format
    [ ] font-display: optional or swap
    [ ] Apply subsetting
    [ ] Use Variable Fonts
    [ ] Set up preloading

[ ] Caching
    [ ] Static assets: immutable + 1 year
    [ ] HTML: no-cache + ETag
    [ ] API: stale-while-revalidate
    [ ] Service Worker caching strategy
    [ ] CDN Edge caching configuration

[ ] Rendering
    [ ] Choose appropriate rendering pattern
    [ ] Streaming with Suspense
    [ ] Set ISR revalidate
    [ ] loading.tsx skeletons

[ ] Monitoring
    [ ] Lighthouse CI automation
    [ ] Web Vitals RUM collection
    [ ] CrUX data monitoring
    [ ] Performance regression alerts

14. Quiz

Test your knowledge with the quizzes below.

Q1. What is the Good threshold for LCP (Largest Contentful Paint) and what are the two most effective ways to improve it?

A1. The Good threshold for LCP is 2.5 seconds or less.

The two most effective improvement methods are:

  1. Setting fetchpriority="high" on the LCP image - Makes the browser download the LCP resource with highest priority.
  2. Using preload link tags - Starts downloading the LCP resource before the parser discovers it.

Additional effective strategies include reducing server response time (CDN, caching), removing render-blocking resources, and image optimization (WebP/AVIF).

Q2. What are the differences between INP and the previous FID metric, and what React patterns help improve INP?

A2. FID measured only the input delay of the first interaction, while INP considers all interactions throughout the entire session and reports the slowest interaction (or p98). Additionally, INP includes not just input delay but also processing time and presentation delay.

React patterns for improvement:

  • useTransition: Process non-urgent state updates at lower priority
  • useDeferredValue: Defer value updates to maintain UI responsiveness
  • scheduler.yield(): Break long tasks to yield to the main thread
Q3. What are the differences between CacheFirst and StaleWhileRevalidate strategies in Service Workers, and what are their ideal use cases?

A3.

CacheFirst: Returns immediately from cache if available; if not, makes a network request. Does not make a network request on cache hit.

  • Ideal use cases: Images, fonts, static assets, and other resources that change infrequently

StaleWhileRevalidate: Returns immediately from cache while simultaneously making a background network request to update the cache.

  • Ideal use cases: API responses, news feeds, and other resources where freshness is needed but immediate response is also important

NetworkFirst is suitable for HTML pages where the latest content is always required.

Q4. How is Streaming SSR better than traditional SSR, and how do you implement it in Next.js App Router?

A4. Traditional SSR could not start sending HTML until all data fetching was complete. Streaming SSR sends HTML chunks immediately as they become ready:

  • Reduces TTFB (not dependent on slow data)
  • Improves FCP (fast components display first)
  • Better perceived performance

Implementation in Next.js App Router:

  1. Wrap slow data components with Suspense components
  2. Provide skeleton/loading UI in the fallback prop
  3. Define route-level loading states with loading.tsx files
  4. Use async/await for data fetching in Server Components
Q5. What are the differences between the Speculation Rules API and traditional link rel="prefetch", and what advantages does it offer?

A5. Traditional link rel="prefetch" only downloads resources, while the Speculation Rules API can fully prerender entire pages.

Key differences:

  • Prefetch: Downloads resources only (HTML, JS, etc.)
  • Prerender (Speculation Rules): Renders the entire page in a hidden tab

Advantages:

  1. Instant page transitions (already fully rendered)
  2. Conditional rules: URL pattern and eagerness level configuration
  3. Browser optimization: Automatically adjusts based on memory/network conditions
  4. Declarative JSON syntax for easy maintenance

Note that it requires Chrome 121+ and you should exclude logout or API endpoints.

15. References

  1. web.dev - Core Web Vitals - Google's Official Core Web Vitals Guide
  2. web.dev - Optimize LCP - LCP Optimization Guide
  3. web.dev - Optimize INP - INP Optimization Guide
  4. web.dev - Optimize CLS - CLS Optimization Guide
  5. Chrome Developers - Speculation Rules API - Speculation Rules Guide
  6. Next.js Documentation - Optimizing - Next.js Optimization Docs
  7. Workbox - Service Worker Libraries - Google Workbox Official Docs
  8. web.dev - Optimize Images - Image Optimization Guide
  9. Partytown - Web Worker for Third-party Scripts - Partytown Official Site
  10. web-vitals - JavaScript Library - web-vitals Library
  11. CrUX Dashboard - Chrome UX Report Documentation
  12. Lighthouse CI - Lighthouse CI Automation
  13. HTTP Caching - MDN - Complete HTTP Caching Guide