1. Core Web Vitals LCP CLS FID
| 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'] });
Scoring Thresholds
| Metric |
Good |
Needs Improvement |
Poor |
| LCP |
<2.5s |
2.5-4s |
>4s |
| FID |
<100ms |
100-300ms |
>300ms |
| CLS |
<0.1 |
0.1-0.25 |
>0.25 |
| INP |
<200ms |
200-500ms |
>500ms |
- Lighthouse: Chrome DevTools audits
- PageSpeed Insights: Real-world data + lab tests
- Chrome UX Report: Field data from real users
- Web Vitals Extension: Real-time monitoring
- Search Console: Core Web Vitals report
- web-vitals library: JavaScript measurement
SEO Impact: Core Web Vitals are Google ranking factors. Pages
meeting all three thresholds rank higher in search results.
2. React Memo useMemo useCallback
| 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 |
// 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} />
</>
);
}
When NOT to Optimize
- Simple components, cheap renders
- Props change frequently anyway
- No measured performance issue
- Premature optimization
- Small lists (<50 items)
- Adding complexity without benefit
Warning: Don't overuse memoization! It adds memory overhead and complexity. Measure first with React DevTools Profiler before optimizing.
| 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 |
// 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 */
}
| List Size |
Without Virtual |
With Virtual |
| 100 items |
~100ms |
~20ms |
| 1,000 items |
~800ms |
~25ms |
| 10,000 items |
~5000ms |
~30ms |
| Memory |
All DOM nodes |
Only visible |
Library Comparison
| Feature |
react-window |
react-virtualized |
| Bundle Size |
6KB |
28KB |
| Variable Heights |
✅ |
✅ |
| Auto-sizing |
❌ |
✅ |
| Performance |
Excellent |
Good |
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
| 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
Size Comparison (1MB JPEG)
| Format |
File Size |
Savings |
| JPEG (baseline) |
1000 KB |
- |
| WebP |
650 KB |
35% |
| AVIF |
500 KB |
50% |
| JPEG (optimized) |
750 KB |
25% |
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 Strategy FOIT FOUT
| 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>
);
}
font-display Values
| Value |
Block Period |
Swap Period |
| auto |
Varies |
Varies |
| block |
~3s |
Infinite |
| swap |
0ms |
Infinite |
| fallback |
~100ms |
~3s |
| optional |
~100ms |
None |
Best Practices
- Use
font-display: swap for most fonts
- Preload 1-2 critical fonts only
- Use WOFF2 format (best compression)
- Self-host fonts for better caching
- Variable fonts reduce file count
- Subset fonts to needed characters
- Match fallback font metrics
- Consider system font stack
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 Loading
| 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();
| Tool |
Purpose |
| Critical |
Extract & inline critical CSS |
| Critters |
Webpack plugin for critical CSS |
| PurgeCSS |
Remove unused CSS |
| UnCSS |
Remove unused CSS selectors |
| penthouse |
Critical path CSS generator |
CSS Loading Patterns
- Critical: Inline in <head>
- Above-fold: Preload high priority
- Below-fold: Async load with media hack
- Route-specific: Code split per route
- Component CSS: CSS-in-JS with SSR
- Utility CSS: Purge unused (Tailwind)
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.