Performance Optimization
Performance Optimization
Performance isn't optional—it's a feature. Slow sites lose users, hurt SEO, and frustrate everyone. This chapter covers the techniques that matter most.
Core Web Vitals
Google's metrics that measure real user experience.
Largest Contentful Paint (LCP)
Measures loading performance—when the largest content element becomes visible.
Target: < 2.5 seconds
Common Causes of Poor LCP:
├── Slow server response (TTFB)
├── Render-blocking CSS/JS
├── Slow resource loading
└── Client-side rendering delay// Measure LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log("LCP:", lastEntry.startTime, "ms");
console.log("LCP Element:", lastEntry.element);
// Report to analytics
analytics.track("LCP", { value: lastEntry.startTime });
}).observe({ type: "largest-contentful-paint", buffered: true });Optimizations:
<!-- Preload LCP image -->
<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high" />
<!-- Use fetchpriority for critical resources -->
<img src="/hero.jpg" fetchpriority="high" alt="Hero" />
<!-- Avoid lazy loading above-the-fold content -->
<img src="/hero.jpg" loading="eager" />
<!-- Inline critical CSS -->
<style>
/* Critical styles for above-the-fold */
.hero {
/* ... */
}
</style>First Input Delay (FID) / Interaction to Next Paint (INP)
Measures interactivity—how quickly the page responds to user input.
Target: < 100ms FID, < 200ms INP
Common Causes of Poor FID/INP:
├── Long JavaScript tasks
├── Heavy main thread work
├── Large JavaScript bundles
└── Third-party scripts// Measure FID
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log("FID:", entry.processingStart - entry.startTime, "ms");
}
}).observe({ type: "first-input", buffered: true });
// Measure INP (all interactions)
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.interactionId) {
const duration = entry.processingEnd - entry.startTime;
console.log("Interaction:", entry.name, duration, "ms");
}
}
}).observe({ type: "event", buffered: true });Optimizations:
// Break up long tasks
function processLargeArray(items) {
const CHUNK_SIZE = 100;
let index = 0;
function processChunk() {
const chunk = items.slice(index, index + CHUNK_SIZE);
chunk.forEach((item) => processItem(item));
index += CHUNK_SIZE;
if (index < items.length) {
// Yield to browser, then continue
requestIdleCallback(processChunk);
}
}
processChunk();
}
// Use requestIdleCallback for non-critical work
requestIdleCallback(
(deadline) => {
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
const task = tasks.shift();
task();
}
},
{ timeout: 2000 },
);
// Defer non-critical JavaScript
<script src="analytics.js" defer></script>;Cumulative Layout Shift (CLS)
Measures visual stability—how much the page shifts unexpectedly.
Target: < 0.1
Common Causes of Poor CLS:
├── Images without dimensions
├── Ads/embeds without reserved space
├── Dynamically injected content
├── Web fonts causing FOIT/FOUT
└── Animations that trigger layout// Measure CLS
let clsValue = 0;
let clsEntries = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Only count if not from user interaction
if (!entry.hadRecentInput) {
clsValue += entry.value;
clsEntries.push(entry);
}
}
console.log("CLS:", clsValue);
}).observe({ type: "layout-shift", buffered: true });Optimizations:
<!-- Always include image dimensions -->
<img src="photo.jpg" width="800" height="600" alt="Photo" />
<!-- Or use aspect-ratio CSS -->
<style>
.image-container {
aspect-ratio: 16 / 9;
width: 100%;
}
</style>
<!-- Reserve space for ads -->
<div class="ad-slot" style="min-height: 250px;">
<!-- Ad loads here -->
</div>
<!-- Avoid inserting content above existing content -->
<!-- Use transforms instead of layout-triggering properties -->
<style>
.animate {
transform: translateY(10px); /* Good */
/* top: 10px; Bad - triggers layout */
}
</style>JavaScript Performance
JavaScript is often the biggest bottleneck in web performance. Unlike HTML and CSS which can be parsed incrementally, JS must be downloaded, parsed, compiled, and executed before it can do anything. Understanding how to optimize JS delivery and execution is critical.
Code Splitting
By default, bundlers create a single JavaScript file containing your entire application. Users must download ALL the code before seeing ANYTHING—even for pages they'll never visit.
Code splitting breaks your bundle into smaller chunks that load on-demand:
Benefits:
- Faster initial page load (smaller initial bundle)
- Users only download code they actually need
- Better caching (unchanged chunks stay cached)
- Parallel loading of chunks
Common splitting strategies:
- Route-based: Each page is a separate chunk
- Component-based: Heavy components loaded when needed
- Interaction-based: Load code when user triggers an action
// Route-based splitting
const routes = {
"/": () => import("./pages/Home"),
"/products": () => import("./pages/Products"),
"/cart": () => import("./pages/Cart"),
};
// Component-based splitting
const HeavyChart = lazy(() => import("./components/Chart"));
// Load on interaction
button.addEventListener("click", async () => {
const { Modal } = await import("./components/Modal");
showModal(Modal);
});Tree Shaking
Tree shaking is the process of eliminating dead code—code that's imported but never actually used. Modern bundlers (Webpack, Rollup, esbuild) analyze your imports and exclude unused exports from the final bundle.
Why this matters:
Lodash has 300+ functions. If you use debounce, you shouldn't ship all 300 functions to users. Tree shaking keeps only what you use.
Requirements for tree shaking:
- Use ES modules (
import/export), not CommonJS (require) - Avoid side effects in module scope
- Use libraries that support tree shaking (look for
"sideEffects": falsein package.json)
// ❌ Imports entire library
import _ from "lodash";
_.debounce(fn, 100);
// ✅ Tree-shakeable import
import debounce from "lodash-es/debounce";
debounce(fn, 100);
// ✅ Named imports (if library supports it)
import { debounce } from "lodash-es";Avoiding Memory Leaks
Memory leaks occur when your application holds references to objects that are no longer needed, preventing garbage collection. Over time, this causes the page to slow down and eventually crash.
Common sources of memory leaks in React/JS:
- Event listeners not removed when components unmount
- Timers (setInterval, setTimeout) not cleared
- Closures that capture large objects
- Subscriptions not unsubscribed
- DOM references held after elements are removed
How to detect memory leaks:
- Open Chrome DevTools → Memory tab
- Take heap snapshot before and after an action
- Compare to find objects that should have been cleaned up
- Use the "Allocation timeline" to see what's being allocated over time
// ❌ Event listener not cleaned up
useEffect(() => {
window.addEventListener("resize", handleResize);
// Missing cleanup!
}, []);
// ✅ Proper cleanup
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// ❌ Timer not cleared
useEffect(() => {
setInterval(pollData, 5000);
}, []);
// ✅ Timer cleared on unmount
useEffect(() => {
const id = setInterval(pollData, 5000);
return () => clearInterval(id);
}, []);
// ❌ Closure holds reference
function createHandler() {
const largeData = new Array(1000000);
return () => {
// largeData never garbage collected while handler exists
console.log(largeData.length);
};
}
// ✅ Release reference when done
function createHandler() {
let largeData = new Array(1000000);
return () => {
if (largeData) {
console.log(largeData.length);
largeData = null; // Allow GC
}
};
}Rendering Performance
The browser's rendering pipeline has distinct phases: Style → Layout → Paint → Composite. Understanding this helps you avoid expensive operations.
Layout (Reflow): Calculating size and position of elements. EXPENSIVE. Paint: Drawing pixels (colors, shadows, text). Moderate cost. Composite: Combining layers. CHEAP (GPU-accelerated).
The key optimization: trigger only composite, avoid layout/paint when possible.
Avoiding Forced Reflows
A forced reflow happens when you read a layout property (like offsetWidth) after modifying styles. The browser must stop everything and recalculate layout to give you an accurate value.
Layout-triggering properties:
offsetWidth,offsetHeight,offsetTopclientWidth,clientHeightscrollWidth,scrollHeightgetComputedStyle()getBoundingClientRect()
The pattern that kills performance:
Read (forces layout) → Write → Read (forces layout AGAIN) → Write...The fix: Batch all reads together, then batch all writes.
// ❌ Causes multiple forced reflows
elements.forEach((el) => {
el.style.width = el.offsetWidth + 10 + "px"; // Read then write
});
// ✅ Batch reads and writes
const widths = elements.map((el) => el.offsetWidth); // All reads first
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + "px"; // Then all writes
});
// ✅ Use requestAnimationFrame
function animate() {
// Reads
const scrollY = window.scrollY;
requestAnimationFrame(() => {
// Writes
elements.forEach((el) => {
el.style.transform = `translateY(${scrollY * 0.5}px)`;
});
});
}CSS Containment
When the browser needs to recalculate layout or paint, it normally has to consider the entire page. CSS containment tells the browser that an element's internals are independent—changes inside won't affect anything outside.
Why this helps:
- Browser can skip recalculating unaffected areas
- Enables off-screen rendering optimizations
- Reduces scope of expensive operations
Containment types:
layout: Element's layout is independentpaint: Element's painting is independentstyle: Counters and quotes don't affect outsidesize: Element's size is independent of children
content-visibility: auto is particularly powerful—it skips rendering for off-screen content entirely, dramatically improving initial render time for long pages.
/* Tell browser this element's internals don't affect outside */
.card {
contain: layout style paint;
/* Or shorthand: */
contain: strict;
}
/* Content-visibility for off-screen content */
.below-fold {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Estimated size */
}Will-Change
The will-change property hints to the browser that an element will be animated or transformed soon. The browser can then promote the element to its own compositing layer and prepare optimizations in advance.
When to use:
- Just before an animation starts (via JavaScript)
- For elements that will definitely be animated
- Only when you've identified a specific performance issue
When NOT to use:
- On every element "just in case"
- Permanently on elements (remove after animation)
- On too many elements (each layer uses GPU memory)
Best practice: Add will-change via JavaScript just before the animation, remove it when done.
/* Hint to browser for optimization */
.animated {
will-change: transform;
}
/* Remove after animation */
.animated.done {
will-change: auto;
}
/* Don't overuse - has memory cost */
/* ❌ Bad */
* {
will-change: transform;
}GPU-Accelerated Properties
Some CSS properties can be animated by the GPU without involving the main thread or causing reflows. These are called compositor-only properties:
transform(translate, rotate, scale, skew)opacity
Why these are special:
- They don't affect layout (no reflow)
- They're handled entirely by the compositor (GPU)
- Animations stay smooth even if main thread is busy
The rule: If you can achieve an effect with transform or opacity, do it. Avoid animating width, height, top, left, margin, or padding.
/* Prefer transform/opacity - GPU accelerated */
.slide-in {
transform: translateX(0);
opacity: 1;
transition:
transform 0.3s,
opacity 0.3s;
}
.slide-in.hidden {
transform: translateX(-100%);
opacity: 0;
}
/* Avoid animating layout properties */
/* ❌ Triggers layout */
.bad {
transition:
width 0.3s,
height 0.3s,
top 0.3s,
left 0.3s;
}Image Optimization
Modern Formats
<picture>
<!-- AVIF: Best compression, limited support -->
<source srcset="image.avif" type="image/avif" />
<!-- WebP: Good compression, wide support -->
<source srcset="image.webp" type="image/webp" />
<!-- JPEG: Fallback -->
<img src="image.jpg" alt="Description" />
</picture>Responsive Images
<img
srcset="image-400.jpg 400w, image-800.jpg 800w, image-1200.jpg 1200w"
sizes="
(max-width: 400px) 400px,
(max-width: 800px) 800px,
1200px
"
src="image-800.jpg"
alt="Description"
loading="lazy"
decoding="async"
/>Lazy Loading
// Native lazy loading
<img src="photo.jpg" loading="lazy" alt="Photo">
// For background images and more control
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
}, { rootMargin: '100px' });
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});Caching Strategies
HTTP Caching
Cache-Control Headers:
├── max-age=31536000, immutable (versioned assets)
├── max-age=0, must-revalidate (dynamic content)
├── private, max-age=3600 (user-specific)
└── no-store (sensitive data)Service Worker Caching
// Stale-while-revalidate pattern
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.open("v1").then((cache) => {
return cache.match(event.request).then((cached) => {
const fetched = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetched;
});
}),
);
});Bundle Analysis
Analyzing Bundle Size
# Webpack
npx webpack-bundle-analyzer stats.json
# Vite
npx vite-bundle-visualizer
# Next.js
ANALYZE=true npm run buildFinding Heavy Dependencies
// Import cost analysis in VS Code
// Shows import size inline
import moment from "moment"; // 67KB!
import dayjs from "dayjs"; // 2KB ✓
import _ from "lodash"; // 70KB!
import { debounce } from "lodash-es"; // 1KB ✓Profiling Tools
Chrome DevTools Performance Panel
// Programmatic performance marks
performance.mark("function-start");
expensiveFunction();
performance.mark("function-end");
performance.measure("expensive-function", "function-start", "function-end");
// View in Performance panel
const measures = performance.getEntriesByType("measure");
console.log(measures);React DevTools Profiler
import { Profiler } from "react";
function onRenderCallback(
id, // Tree ID
phase, // "mount" or "update"
actualDuration, // Time spent rendering
baseDuration, // Estimated time without memoization
startTime,
commitTime,
) {
console.log(`${id} ${phase}: ${actualDuration}ms`);
}
<Profiler id="MyComponent" onRender={onRenderCallback}>
<MyComponent />
</Profiler>;Web Vitals Library
import { getLCP, getFID, getCLS, getTTFB, getFCP } from "web-vitals";
function sendToAnalytics({ name, value, id }) {
gtag("event", name, {
event_category: "Web Vitals",
event_label: id,
value: Math.round(name === "CLS" ? value * 1000 : value),
non_interaction: true,
});
}
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);
getFCP(sendToAnalytics);Performance Budget
Setting Budgets
// Example performance budget
const budget = {
// Timing budgets
"first-contentful-paint": 1800, // ms
"largest-contentful-paint": 2500, // ms
"cumulative-layout-shift": 0.1,
"first-input-delay": 100, // ms
// Size budgets
"total-bundle-size": 250, // KB
"initial-js": 150, // KB
"initial-css": 50, // KB
images: 500, // KB
// Request budgets
requests: 50,
"third-party-requests": 10,
};Enforcing Budgets
// Lighthouse CI configuration
module.exports = {
ci: {
assert: {
assertions: {
"first-contentful-paint": ["error", { maxNumericValue: 1800 }],
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
"total-blocking-time": ["warning", { maxNumericValue: 300 }],
},
},
},
};Summary
| Metric | Target | Key Optimizations |
|---|---|---|
| LCP | < 2.5s | Preload hero image, inline critical CSS |
| FID/INP | < 100ms | Code split, defer non-critical JS |
| CLS | < 0.1 | Specify image dimensions, reserve space |
| TTFB | < 800ms | CDN, caching, optimize server |
| Bundle Size | < 250KB | Tree shaking, code splitting |
Key Principles:
- Measure first, optimize second
- Focus on user-perceived performance
- Set and enforce performance budgets
- Monitor real user metrics (RUM)
- Performance is a continuous process