✍️ 필사 모드: Web Performance Optimization Complete Guide 2025: Core Web Vitals, LCP/INP/CLS, Loading Strategies
EnglishTable 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
| Metric | Improvement Effect |
|---|---|
| 0.1s load time reduction | 8% conversion increase (Walmart) |
| 50% load time reduction | 12% revenue increase (AutoAnything) |
| 2.2s load time reduction | 15.4% download increase (Mozilla) |
| 10-point performance score increase | 5-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:
imgelementsimageelements insidesvg- Poster images from
videoelements - 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):
┌─────────┬─────────────┬──────────────┬───────────┬─────────────┐
│ Format │ Compression │ Transparency │ Animation │ Browser │
├─────────┼─────────────┼──────────────┼───────────┼─────────────┤
│ JPEG │ Baseline │ No │ No │ 100% │
│ PNG │ Low │ Yes │ No │ 100% │
│ WebP │ 25-34% │ Yes │ Yes │ 97%+ │
│ AVIF │ 50%+ │ Yes │ Yes │ 92%+ │
│ JPEG XL │ 35-60% │ Yes │ Yes │ Limited │
└─────────┴─────────────┴──────────────┴───────────┴─────────────┘
<!-- 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:
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│ │ TTFB │ FCP │ TTI │ SEO │
├──────────┼──────────┼──────────┼──────────┼──────────┤
│ CSR │ Fast │ Slow │ Slow │ Poor │
│ SSR │ Slow │ Fast │ Medium │ Good │
│ SSG │ V. Fast │ V. Fast │ Fast │ Good │
│ ISR │ V. Fast │ V. Fast │ Fast │ Good │
│ Streaming│ Fast │ V. Fast │ Fast │ Good │
└──────────┴──────────┴──────────┴──────────┴──────────┘
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
9.1 Link Prefetch and Route Prefetch
<!-- 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):
┌───────────────────────────┬────────┐
│ Metric │ Weight │
├───────────────────────────┼────────┤
│ 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:
- Setting
fetchpriority="high"on the LCP image - Makes the browser download the LCP resource with highest priority. - Using
preloadlink 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 priorityuseDeferredValue: Defer value updates to maintain UI responsivenessscheduler.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:
- Wrap slow data components with
Suspensecomponents - Provide skeleton/loading UI in the
fallbackprop - Define route-level loading states with
loading.tsxfiles - Use
async/awaitfor 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:
- Instant page transitions (already fully rendered)
- Conditional rules: URL pattern and eagerness level configuration
- Browser optimization: Automatically adjusts based on memory/network conditions
- Declarative JSON syntax for easy maintenance
Note that it requires Chrome 121+ and you should exclude logout or API endpoints.
15. References
- web.dev - Core Web Vitals - Google's Official Core Web Vitals Guide
- web.dev - Optimize LCP - LCP Optimization Guide
- web.dev - Optimize INP - INP Optimization Guide
- web.dev - Optimize CLS - CLS Optimization Guide
- Chrome Developers - Speculation Rules API - Speculation Rules Guide
- Next.js Documentation - Optimizing - Next.js Optimization Docs
- Workbox - Service Worker Libraries - Google Workbox Official Docs
- web.dev - Optimize Images - Image Optimization Guide
- Partytown - Web Worker for Third-party Scripts - Partytown Official Site
- web-vitals - JavaScript Library - web-vitals Library
- CrUX Dashboard - Chrome UX Report Documentation
- Lighthouse CI - Lighthouse CI Automation
- HTTP Caching - MDN - Complete HTTP Caching Guide
현재 단락 (1/1029)
Web performance directly impacts user experience, business outcomes, and SEO rankings. According to ...