Performance Optimization Best Practices

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

Measurement Tools

  • 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

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} />
    </>
  );
}

When to Optimize

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.

3. Virtual Scrolling Windowing Lists

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 */
}

Performance Impact

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

Image Optimization Checklist

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();

Critical CSS Tools

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.