Performance Optimization Strategies
1. Core Web Vitals (LCP, CLS, FID, INP - Google Metrics)
| Metric | Measurement | Good Threshold | Optimization Strategy |
|---|---|---|---|
| LCP - Largest Contentful Paint | Time to render largest visible element | < 2.5s | Optimize images, reduce render-blocking resources, use CDN, server-side rendering |
| FID - First Input Delay | Time from user interaction to browser response | < 100ms | Minimize JavaScript, code splitting, defer non-critical JS, use Web Workers |
| CLS - Cumulative Layout Shift | Visual stability, unexpected layout shifts | < 0.1 | Size attributes on images/videos, reserve space for ads, avoid inserting content above existing |
| INP - Interaction to Next Paint NEW | Responsiveness to all user interactions | < 200ms | Optimize event handlers, reduce main thread work, yield to browser |
| TTFB - Time to First Byte | Time from request to first byte received | < 800ms | CDN, edge caching, optimize database queries, reduce redirects |
| FCP - First Contentful Paint | Time to render any content | < 1.8s | Inline critical CSS, preload fonts, optimize server response time |
Example: Core Web Vitals Optimization
// LCP Optimization
// 1. Preload hero image
<link rel="preload" as="image" href="hero.jpg" />
// 2. Optimize image loading
<img
src="hero-800.jpg"
srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w"
sizes="(max-width: 800px) 400px, 800px"
loading="eager"
fetchpriority="high"
alt="Hero image"
/>
// 3. Use next/image for automatic optimization (Next.js)
import Image from 'next/image';
<Image
src="/hero.jpg"
width={1200}
height={600}
priority
alt="Hero"
/>
// FID Optimization
// 1. Code splitting
const HeavyComponent = lazy(() => import('./HeavyComponent'));
// 2. Defer non-critical JavaScript
<script defer src="analytics.js"></script>
// 3. Use Web Worker for heavy computation
const worker = new Worker('compute.worker.js');
worker.postMessage({ data: largeDataset });
worker.onmessage = (e) => {
console.log('Result:', e.data);
};
// CLS Optimization
// 1. Always specify dimensions
<img src="image.jpg" width="800" height="600" alt="..." />
// 2. Reserve space for dynamic content
.ad-slot {
min-height: 250px; /* Prevent layout shift */
}
// 3. Avoid inserting content above existing
// ❌ Bad: Inserting banner shifts content
document.body.prepend(banner);
// ✅ Good: Reserve space or append
<div class="banner-placeholder" style="height: 60px">
<!-- Banner loads here without shift -->
</div>
// 4. Font loading without FOUT
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: optional; /* Avoid layout shift */
}
// Measure Web Vitals
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify(metric);
navigator.sendBeacon('/analytics', body);
}
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);
// Performance Observer
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.name, entry.startTime);
}
});
observer.observe({ entryTypes: ['largest-contentful-paint', 'layout-shift'] });
SEO Impact: Core Web Vitals are Google ranking factors. Pages
meeting all three thresholds rank higher in search results.
2. React Performance (memo, useMemo, useCallback, PureComponent)
| Hook/API | Purpose | Use Case | When to Use |
|---|---|---|---|
| React.memo() | Memoize component to prevent re-renders | Pure components with expensive rendering | Component re-renders frequently with same props |
| useMemo() | Memoize expensive computed values | Heavy calculations, filtered/sorted lists | Computation is expensive and depends on specific values |
| useCallback() | Memoize function references | Callbacks passed to memoized children | Function passed as prop causes child re-renders |
| useTransition() | Mark updates as non-urgent (React 18) | Search filtering, tab switching | Keep UI responsive during heavy updates |
| useDeferredValue() | Defer updating a value (React 18) | Debouncing without setTimeout | Expensive re-renders from input changes |
Example: React Performance Optimizations
// React.memo - Prevent unnecessary re-renders
const ExpensiveComponent = React.memo(({ data, onClick }) => {
console.log('Rendering ExpensiveComponent');
return (
<div onClick={onClick}>
{data.map(item => <Item key={item.id} {...item} />)}
</div>
);
});
// Custom comparison function
const UserCard = React.memo(
({ user }) => <div>{user.name}</div>,
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return prevProps.user.id === nextProps.user.id;
}
);
// useMemo - Memoize expensive calculations
function ProductList({ products, filter }) {
// ❌ Bad: Recalculates on every render
const filtered = products.filter(p => p.category === filter);
// ✅ Good: Only recalculates when products or filter changes
const filteredProducts = useMemo(() => {
console.log('Filtering products...');
return products.filter(p => p.category === filter);
}, [products, filter]);
return <div>{filteredProducts.map(p => <Product key={p.id} {...p} />)}</div>;
}
// useCallback - Memoize function references
function Parent() {
const [count, setCount] = useState(0);
const [value, setValue] = useState('');
// ❌ Bad: New function on every render, Child re-renders
const handleClick = () => setCount(c => c + 1);
// ✅ Good: Same function reference, Child doesn't re-render
const handleClickMemo = useCallback(() => {
setCount(c => c + 1);
}, []); // Empty deps - function never changes
return (
<div>
<input value={value} onChange={(e) => setValue(e.target.value)} />
<MemoizedChild onClick={handleClickMemo} />
</div>
);
}
const MemoizedChild = React.memo(({ onClick }) => {
console.log('Child render');
return <button onClick={onClick}>Increment</button>;
});
// useTransition - Non-blocking updates (React 18)
function SearchResults() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const value = e.target.value;
setQuery(value); // Urgent: Update input immediately
startTransition(() => {
// Non-urgent: Filter large list without blocking input
setFilteredResults(expensiveFilter(value));
});
};
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <Results data={filteredResults} />}
</>
);
}
// useDeferredValue - Defer expensive updates
function SearchWithDefer() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// deferredQuery updates with lower priority
const results = useMemo(() => {
return expensiveFilter(deferredQuery);
}, [deferredQuery]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results data={results} />
</>
);
}
Warning: Don't overuse memoization! It adds memory overhead and complexity. Measure first with React DevTools Profiler before optimizing.
3. Virtual Scrolling (Windowing, react-window, react-virtualized)
| Library | Features | Description | Use Case |
|---|---|---|---|
| react-window | FixedSizeList, VariableSizeList |
Lightweight virtualization library by same author as react-virtualized | Simple lists, grids, efficient rendering of 1000+ items |
| react-virtualized | List, Grid, Table, Collection |
Feature-rich virtualization with auto-sizing, infinite loading | Complex grids, tables, variable heights, legacy projects |
| TanStack Virtual | Framework agnostic |
Headless UI for virtualizing, works with React/Vue/Solid | Custom virtualization, framework flexibility |
| Intersection Observer | Native API |
Detect visibility, implement infinite scroll without library | Lazy loading images, infinite pagination, simple cases |
| CSS content-visibility | content-visibility: auto |
Browser-native rendering optimization for off-screen content | Long pages, off-screen optimization, progressive enhancement |
Example: Virtual Scrolling Implementation
// react-window - Fixed size list
import { FixedSizeList } from 'react-window';
function VirtualList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
Row {index}: {items[index].name}
</div>
);
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
// Variable size list
import { VariableSizeList } from 'react-window';
function VariableList({ items }) {
const getItemSize = (index) => {
// Dynamic height based on content
return items[index].content.length > 100 ? 120 : 60;
};
return (
<VariableSizeList
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{Row}
</VariableSizeList>
);
}
// Infinite loading with react-window
import InfiniteLoader from 'react-window-infinite-loader';
function InfiniteList({ hasNextPage, loadMoreItems }) {
const itemCount = hasNextPage ? items.length + 1 : items.length;
const isItemLoaded = (index) => !hasNextPage || index < items.length;
return (
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={itemCount}
loadMoreItems={loadMoreItems}
>
{({ onItemsRendered, ref }) => (
<FixedSizeList
height={600}
itemCount={itemCount}
itemSize={50}
onItemsRendered={onItemsRendered}
ref={ref}
>
{Row}
</FixedSizeList>
)}
</InfiniteLoader>
);
}
// Intersection Observer for infinite scroll
function useInfiniteScroll(callback) {
const observerRef = useRef(null);
const loadMoreRef = useCallback((node) => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
callback();
}
}, { threshold: 1.0 });
if (node) observerRef.current.observe(node);
}, [callback]);
return loadMoreRef;
}
function InfiniteScrollList({ items, loadMore, hasMore }) {
const loadMoreRef = useInfiniteScroll(loadMore);
return (
<div>
{items.map((item) => <Item key={item.id} {...item} />)}
{hasMore && <div ref={loadMoreRef}>Loading...</div>}
</div>
);
}
// CSS content-visibility
.long-content {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Estimated height */
}
When to Virtualize: Lists with 100+ items, tables with many
rows, chat messages, feeds. Saves 90% render time for large lists.
4. Image Optimization (WebP, AVIF, Next/Image, Lazy Loading)
| Format | Compression | Browser Support | Use Case |
|---|---|---|---|
| AVIF NEW | 50% smaller than JPEG | Chrome 85+, Firefox 93+ | Best quality/size ratio, modern browsers, with fallback |
| WebP | 25-35% smaller than JPEG | 95% global support | Production-ready, excellent compression, wide support |
| JPEG | Baseline format | Universal | Fallback format, legacy browser support |
| PNG | Lossless, larger files | Universal | Transparency needed, logos, graphics with text |
| SVG | Vector, scalable | Universal | Icons, logos, illustrations, infinite scaling |
Example: Image Optimization Techniques
// Modern image with fallback (picture element)
<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<img src="hero.jpg" alt="Hero image" width="1200" height="600" />
</picture>
// Responsive images with srcset
<img
src="product-800.jpg"
srcset="
product-400.jpg 400w,
product-800.jpg 800w,
product-1200.jpg 1200w,
product-1600.jpg 1600w
"
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
alt="Product"
loading="lazy"
decoding="async"
/>
// Next.js Image component (automatic optimization)
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
quality={85}
priority // LCP image
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
// Lazy loading with Intersection Observer
function LazyImage({ src, alt }) {
const [imageSrc, setImageSrc] = useState(null);
const imgRef = useRef();
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setImageSrc(src);
observer.disconnect();
}
},
{ rootMargin: '50px' } // Load 50px before visible
);
if (imgRef.current) observer.observe(imgRef.current);
return () => observer.disconnect();
}, [src]);
return (
<img
ref={imgRef}
src={imageSrc || 'placeholder.jpg'}
alt={alt}
loading="lazy"
/>
);
}
// Progressive JPEG with blur-up
.image-container {
position: relative;
overflow: hidden;
}
.image-placeholder {
filter: blur(20px);
transform: scale(1.1);
transition: opacity 0.3s;
}
.image-placeholder.loaded {
opacity: 0;
}
// Sharp (Node.js) - Generate optimized images
const sharp = require('sharp');
await sharp('input.jpg')
.resize(800, 600, { fit: 'cover' })
.webp({ quality: 85 })
.toFile('output.webp');
await sharp('input.jpg')
.resize(800, 600)
.avif({ quality: 80 })
.toFile('output.avif');
// Cloudinary transformation URL
<img src="https://res.cloudinary.com/demo/image/upload/
w_800,h_600,c_fill,f_auto,q_auto/sample.jpg" />
// f_auto: automatic format (AVIF/WebP/JPEG)
// q_auto: automatic quality
Image Loading Strategy
- Above fold: Priority load, eager, preload LCP image
- Below fold: Lazy load with loading="lazy"
- Format: Serve AVIF → WebP → JPEG with picture element
- Responsive: Multiple sizes with srcset, let browser choose
5. Font Loading Strategies (font-display, FOIT, FOUT, FOFT)
| Strategy | font-display | Behavior | Use Case |
|---|---|---|---|
| FOIT - Flash of Invisible Text | block |
Hide text until font loads (3s timeout) | Brand-critical fonts where design consistency matters most |
| FOUT - Flash of Unstyled Text | swap |
Show fallback immediately, swap when loaded | Content-first, best for performance, recommended |
| font-display: optional | optional |
Use font if cached, otherwise use fallback permanently | Best CLS score, no layout shift, fast connections |
| font-display: fallback | fallback |
100ms block period, 3s swap period | Balance between swap and optional |
| Preload Fonts | <link rel="preload"> |
Load critical fonts early in page load | Above-fold fonts, reduce FOUT duration |
Example: Font Loading Optimization
// @font-face with font-display
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2'),
url('/fonts/custom.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap; /* Show fallback immediately */
}
// Preload critical fonts
<head>
<link
rel="preload"
href="/fonts/custom.woff2"
as="font"
type="font/woff2"
crossorigin
/>
</head>
// Google Fonts with font-display
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap"
rel="stylesheet"
/>
// Variable fonts (single file, multiple weights)
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900; /* All weights in one file */
font-display: swap;
}
// Font loading API
const font = new FontFace(
'CustomFont',
'url(/fonts/custom.woff2)',
{ weight: '400', style: 'normal' }
);
await font.load();
document.fonts.add(font);
// React hook for font loading
function useFontLoaded(fontFamily) {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
document.fonts.ready.then(() => {
const fontLoaded = document.fonts.check(`12px ${fontFamily}`);
setLoaded(fontLoaded);
});
}, [fontFamily]);
return loaded;
}
function App() {
const fontLoaded = useFontLoaded('CustomFont');
return (
<div className={fontLoaded ? 'font-loaded' : 'font-loading'}>
Content
</div>
);
}
// System font stack (no loading needed)
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
// Fallback font matching (reduce layout shift)
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
size-adjust: 105%; /* Match fallback metrics */
ascent-override: 95%;
descent-override: 25%;
}
// Next.js Font Optimization
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter'
});
export default function App({ Component, pageProps }) {
return (
<main className={inter.className}>
<Component {...pageProps} />
</main>
);
}
CLS Impact: Use
font-display: optional for zero layout
shift or match fallback font metrics with size-adjust to minimize FOUT visual impact.
6. Critical CSS (Above-fold, Inline Styles, CSS Extraction)
| Technique | Implementation | Description | Benefit |
|---|---|---|---|
| Inline Critical CSS | <style> in <head> |
Extract and inline CSS needed for above-fold content | Eliminate render-blocking CSS, faster FCP |
| Async CSS Loading | media="print" onload |
Load non-critical CSS asynchronously without blocking render | Non-blocking CSS, progressive enhancement |
| Preload CSS | <link rel="preload"> |
High-priority fetch for critical stylesheets | Earlier CSS discovery, parallel loading |
| CSS-in-JS SSR | styled-components, Emotion |
Extract critical CSS during server-side rendering | Automatic critical CSS extraction, component-level |
| Tailwind Purge | content: ['./src/**'] |
Remove unused utility classes from production build | 90% size reduction, only ship used CSS |
Example: Critical CSS Implementation
// Inline critical CSS in HTML
<!DOCTYPE html>
<html>
<head>
<style>
/* Critical CSS - above fold only */
body { margin: 0; font-family: system-ui; }
.header { height: 60px; background: #333; }
.hero { height: 400px; background: url(hero.jpg); }
/* ... only styles for visible content ... */
</style>
<!-- Async load remaining CSS -->
<link
rel="preload"
href="/styles/main.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
</head>
<body>...</body>
</html>
// Critical CSS extraction (Node.js)
const critical = require('critical');
await critical.generate({
inline: true,
base: 'dist/',
src: 'index.html',
target: {
html: 'index-critical.html',
css: 'critical.css'
},
width: 1300,
height: 900
});
// Webpack plugin
const CriticalCssPlugin = require('critical-css-webpack-plugin');
module.exports = {
plugins: [
new CriticalCssPlugin({
base: 'dist/',
src: 'index.html',
inline: true,
minify: true,
extract: true,
width: 1300,
height: 900
})
]
};
// Next.js with styled-components
// pages/_document.js
import Document from 'next/document';
import { ServerStyleSheet } from 'styled-components';
export default class MyDocument extends Document {
static async getInitialProps(ctx) {
const sheet = new ServerStyleSheet();
const originalRenderPage = ctx.renderPage;
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) =>
sheet.collectStyles(<App {...props} />)
});
const initialProps = await Document.getInitialProps(ctx);
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
)
};
} finally {
sheet.seal();
}
}
}
// Tailwind CSS purge configuration
// tailwind.config.js
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}'
],
theme: {},
plugins: []
};
// PurgeCSS configuration
const purgecss = require('@fullhuman/postcss-purgecss');
module.exports = {
plugins: [
purgecss({
content: ['./src/**/*.html', './src/**/*.jsx'],
safelist: ['random', 'yep', 'button'] // Classes to keep
})
]
};
// Loadable Components with CSS
import loadable from '@loadable/component';
const HeavyComponent = loadable(() => import('./Heavy'), {
fallback: <div>Loading...</div>
});
// Async CSS loading utility
function loadCSS(href) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
return new Promise((resolve, reject) => {
link.onload = resolve;
link.onerror = reject;
});
}
// Usage
await loadCSS('/styles/modal.css');
showModal();
Critical CSS Strategy
- Inline: ~14KB of critical CSS for above-fold content
- Async: Load remaining CSS without blocking render
- Purge: Remove unused CSS, target <50KB total
- Result: Faster FCP (First Contentful Paint) by 1-2 seconds
Balance: Don't inline too much CSS (>14KB hurts performance). Focus on truly critical above-fold styles only.