โšก

Performance Audit

๐Ÿ‘จโ€๐Ÿณ Chefโฑ๏ธ 60 minutes

๐Ÿ“‹ Suggested prerequisites

  • โ€ขA working web application
  • โ€ขBasic Chrome DevTools

Performance Audit: From Slow to Fast

In this hands-on workshop you'll take a slow application and optimize it step by step. This isn't theory: you'll measure, identify problems, and see real improvements in the numbers.

When finished, you'll have a checklist you can use on any future project.


The Problem Application

First, we'll create an application with all the typical performance problems. This way you'll learn to identify and fix them.

Project Structure

slow-app/
โ”œโ”€โ”€ package.json
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ App.tsx
โ”‚   โ”œโ”€โ”€ components/
โ”‚   โ”‚   โ”œโ”€โ”€ HeavyComponent.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ ProductList.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ ImageGallery.tsx
โ”‚   โ”‚   โ””โ”€โ”€ SearchBox.tsx
โ”‚   โ”œโ”€โ”€ utils/
โ”‚   โ”‚   โ””โ”€โ”€ slowOperations.ts
โ”‚   โ””โ”€โ”€ index.tsx
โ””โ”€โ”€ public/
    โ””โ”€โ”€ images/
        โ””โ”€โ”€ (large unoptimized images)

package.json

{
  "name": "slow-app",
  "version": "1.0.0",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "lodash": "^4.17.21",
    "moment": "^2.29.4",
    "axios": "^1.6.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "typescript": "^5.0.0",
    "vite": "^5.0.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }
}

App.tsx - The Main Component (with problems)

// BEFORE: App with multiple performance problems
import React, { useState, useEffect } from 'react'
import moment from 'moment' // Heavy library fully imported
import _ from 'lodash' // All of lodash imported
import HeavyComponent from './components/HeavyComponent'
import ProductList from './components/ProductList'
import ImageGallery from './components/ImageGallery'
import SearchBox from './components/SearchBox'

function App() {
  const [products, setProducts] = useState([])
  const [loading, setLoading] = useState(true)
  const [searchTerm, setSearchTerm] = useState('')

  // Problem 1: Fetch without cache
  useEffect(() => {
    fetch('https://fakestoreapi.com/products')
      .then(res => res.json())
      .then(data => {
        setProducts(data)
        setLoading(false)
      })
  }, [])

  // Problem 2: Filtering on every render
  const filteredProducts = products.filter(p =>
    p.title.toLowerCase().includes(searchTerm.toLowerCase())
  )

  // Problem 3: Inefficient date formatting
  const formatDate = (date) => {
    return moment(date).format('MMMM Do YYYY, h:mm:ss a')
  }

  // Problem 4: Heavy calculation without memoization
  const expensiveCalculation = () => {
    let result = 0
    for (let i = 0; i < 10000000; i++) {
      result += Math.sqrt(i) * Math.random()
    }
    return result
  }

  const calculatedValue = expensiveCalculation()

  return (
    <div className="app">
      <header>
        <h1>Demo Store</h1>
        <p>Updated: {formatDate(new Date())}</p>
        <p>Calculated value: {calculatedValue.toFixed(2)}</p>
      </header>

      <SearchBox
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />

      <HeavyComponent />

      <ImageGallery />

      {loading ? (
        <p>Loading...</p>
      ) : (
        <ProductList products={filteredProducts} />
      )}
    </div>
  )
}

export default App

HeavyComponent.tsx - Heavy Component

// BEFORE: Component that loads synchronously
import React from 'react'
// Unnecessary heavy imports
import Chart from 'chart.js/auto' // 200KB+
import * as THREE from 'three' // 500KB+

function HeavyComponent() {
  // Heavy initialization on every render
  const initializeChart = () => {
    // Complex configuration...
    return null
  }

  return (
    <div className="heavy-component">
      <h2>Component with Charts</h2>
      <div id="chart-container"></div>
    </div>
  )
}

export default HeavyComponent

ProductList.tsx - List Without Virtualization

// BEFORE: Renders all items at once
import React from 'react'

interface Product {
  id: number
  title: string
  price: number
  image: string
  description: string
}

function ProductList({ products }: { products: Product[] }) {
  return (
    <div className="product-list">
      {products.map(product => (
        <div key={product.id} className="product-card">
          {/* Unoptimized image */}
          <img
            src={product.image}
            alt={product.title}
            style={{ width: '200px', height: '200px' }}
          />
          <h3>{product.title}</h3>
          <p>{product.description}</p>
          <p className="price">${product.price}</p>
        </div>
      ))}
    </div>
  )
}

export default ProductList

SearchBox.tsx - Search Without Debounce

// BEFORE: Every keystroke triggers re-render
import React from 'react'

function SearchBox({ value, onChange }) {
  return (
    <div className="search-box">
      <input
        type="text"
        placeholder="Search products..."
        value={value}
        onChange={onChange}
      />
    </div>
  )
}

export default SearchBox

Step 1: Initial Audit with Lighthouse

Running Lighthouse

  1. Open Chrome DevTools (F12)
  2. Go to the Lighthouse tab
  3. Select:
    • Mode: Navigation
    • Device: Mobile
    • Categories: Performance, Best Practices, SEO
  4. Click Analyze page load

Interpreting Results

INITIAL RESULTS (example):
==========================
Performance Score: 35/100

Core Web Vitals:
- LCP (Largest Contentful Paint): 4.2s (BAD - should be < 2.5s)
- FID (First Input Delay): 320ms (BAD - should be < 100ms)
- CLS (Cumulative Layout Shift): 0.25 (NEEDS IMPROVEMENT - should be < 0.1)

Additional metrics:
- First Contentful Paint: 2.1s
- Speed Index: 4.8s
- Time to Interactive: 6.3s
- Total Blocking Time: 890ms

Identified opportunities:
- Reduce unused JavaScript: 450KB potential savings
- Properly size images: 320KB potential savings
- Serve images in modern formats: 180KB potential savings
- Eliminate render-blocking resources: 1.2s potential savings

Key Diagnostics

ProblemCauseImpact
High TBTExpensive calculation blocking main threadBad FID
High LCPLarge images, blocking JSSlow UX
High CLSImages without dimensionsVisual jumps

Step 2: DevTools Performance Tab

Recording a Session

  1. Open DevTools > Performance
  2. Click the record button (circle)
  3. Interact with the app (scroll, search, etc.)
  4. Click stop
  5. Analyze the flamegraph

What to Look For

Problem timeline:
=================

[====SCRIPTING (yellow)====]  <- JavaScript executing
     [===RENDERING (purple)===] <- Layout and paint
          [=PAINTING (green)=]  <- Painting pixels

Common problems:
- Long Tasks (red bars): Tasks > 50ms that block
- Forced Reflow: Forced synchronous layout
- Excessive Painting: Unnecessary repainting

Identifying Long Tasks

Look for red bars in the "Main" line. Each one is a task that blocks the main thread for more than 50ms.

In our problematic app, you'll see:

  1. expensiveCalculation() - 200ms+ blocking
  2. Moment.js parsing - 50ms+ on each render
  3. Product filtering - Grows with more products

Step 3: Network Waterfall

Analyzing Resource Loading

  1. DevTools > Network
  2. Reload the page (Ctrl+Shift+R for clean cache)
  3. Observe the waterfall
Problematic waterfall:
======================

|--document.html (100ms)
   |--bundle.js (800KB, 2s) <-- BLOCKS EVERYTHING
      |--chunk-lodash.js (70KB)
      |--chunk-moment.js (200KB)
      |--chunk-three.js (500KB)
         |--products-api (200ms)
            |--image1.jpg (500KB, 1s)
            |--image2.jpg (450KB, 0.9s)
            |--image3.jpg (600KB, 1.2s)

Total time: ~5.3s

Identified Problems

  1. Huge bundle: 800KB of JS in a single file
  2. Sequential loading: Images wait for JS
  3. No compression: Images in original format
  4. No cache headers: Every visit downloads everything

Step 4: Fix Heavy Imports

Before: Import Everything

// BAD: Imports the entire library
import moment from 'moment' // 300KB
import _ from 'lodash' // 70KB

After: Selective Imports

// GOOD: Only what you need
import { format } from 'date-fns' // 2KB vs 300KB
import debounce from 'lodash/debounce' // 1KB vs 70KB

// Or even better, native functions:
const formatDate = (date: Date) => {
  return new Intl.DateTimeFormat('en', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit'
  }).format(date)
}

Impact

Bundle savings:
- moment.js: -300KB
- full lodash: -70KB
- Total: -370KB (46% of original bundle)

Step 5: Lazy Loading Components

Before: Everything Loads at Start

// BAD: HeavyComponent loads even if not visible
import HeavyComponent from './components/HeavyComponent'

function App() {
  return (
    <div>
      <Hero />
      <HeavyComponent /> {/* Loads 500KB even if user doesn't scroll */}
    </div>
  )
}

After: Lazy Loading with Suspense

// GOOD: Loads only when needed
import React, { lazy, Suspense } from 'react'

const HeavyComponent = lazy(() => import('./components/HeavyComponent'))

function App() {
  return (
    <div>
      <Hero />
      <Suspense fallback={<div className="skeleton">Loading charts...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  )
}

With Intersection Observer (load on scroll)

import React, { lazy, Suspense, useState, useEffect, useRef } from 'react'

const HeavyComponent = lazy(() => import('./components/HeavyComponent'))

function LazySection() {
  const [isVisible, setIsVisible] = useState(false)
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true)
          observer.disconnect()
        }
      },
      { rootMargin: '100px' } // Load 100px before visible
    )

    if (ref.current) {
      observer.observe(ref.current)
    }

    return () => observer.disconnect()
  }, [])

  return (
    <div ref={ref}>
      {isVisible ? (
        <Suspense fallback={<div className="skeleton">Loading...</div>}>
          <HeavyComponent />
        </Suspense>
      ) : (
        <div className="placeholder" style={{ height: '400px' }} />
      )}
    </div>
  )
}

Step 6: Image Optimization

Before: Unoptimized Images

// BAD: Original 2MB image
<img
  src="/images/hero.jpg"
  alt="Hero"
/>

After: With next/image (Next.js)

import Image from 'next/image'

// GOOD: Automatic optimization
<Image
  src="/images/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority // For above-the-fold images
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>

Without Next.js: Manual Optimization

// Optimized image component
function OptimizedImage({
  src,
  alt,
  width,
  height
}: {
  src: string
  alt: string
  width: number
  height: number
}) {
  const webpSrc = src.replace(/\.(jpg|png)$/, '.webp')

  return (
    <picture>
      {/* WebP for modern browsers */}
      <source srcSet={webpSrc} type="image/webp" />
      {/* Fallback for old browsers */}
      <img
        src={src}
        alt={alt}
        width={width}
        height={height}
        loading="lazy"
        decoding="async"
        style={{
          aspectRatio: `${width}/${height}`,
          objectFit: 'cover'
        }}
      />
    </picture>
  )
}

Responsive Images

<img
  src="/images/product-400.jpg"
  srcSet="
    /images/product-400.jpg 400w,
    /images/product-800.jpg 800w,
    /images/product-1200.jpg 1200w
  "
  sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
  alt="Product"
  loading="lazy"
  width={800}
  height={600}
/>

Script to Convert Images

# Install sharp-cli
npm install -g sharp-cli

# Convert to WebP with 80% quality
sharp -i input.jpg -o output.webp -f webp -q 80

# Resize and convert
sharp -i input.jpg -o output-400.webp -f webp -q 80 --width 400
sharp -i input.jpg -o output-800.webp -f webp -q 80 --width 800

Step 7: Memoization of Calculations

Before: Calculation on Every Render

function App() {
  const [count, setCount] = useState(0)

  // BAD: Runs on EVERY render
  const expensiveValue = heavyCalculation(data)

  return (
    <div>
      <p>Result: {expensiveValue}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
    </div>
  )
}

After: useMemo for Values

import { useMemo } from 'react'

function App() {
  const [count, setCount] = useState(0)
  const [data, setData] = useState(initialData)

  // GOOD: Only recalculates when data changes
  const expensiveValue = useMemo(() => {
    return heavyCalculation(data)
  }, [data])

  return (
    <div>
      <p>Result: {expensiveValue}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
    </div>
  )
}

useCallback for Functions

import { useCallback, memo } from 'react'

// Memoized child component
const ExpensiveChild = memo(function ExpensiveChild({
  onClick,
  data
}: {
  onClick: () => void
  data: any[]
}) {
  console.log('ExpensiveChild rendered')
  return <div onClick={onClick}>{/* ... */}</div>
})

function Parent() {
  const [count, setCount] = useState(0)

  // GOOD: Stable function between renders
  const handleClick = useCallback(() => {
    console.log('clicked')
  }, [])

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <ExpensiveChild onClick={handleClick} data={stableData} />
    </div>
  )
}

Step 8: Search Debouncing

Before: Search on Every Keystroke

function SearchBox({ onSearch }) {
  const [term, setTerm] = useState('')

  // BAD: Fires search on every key
  const handleChange = (e) => {
    setTerm(e.target.value)
    onSearch(e.target.value) // API call on every keystroke!
  }

  return <input value={term} onChange={handleChange} />
}

After: With Debounce

import { useState, useCallback, useEffect } from 'react'

// Custom hook for debounce
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

// Usage in component
function SearchBox({ onSearch }) {
  const [term, setTerm] = useState('')
  const debouncedTerm = useDebounce(term, 300)

  // Only searches when debounced value changes
  useEffect(() => {
    if (debouncedTerm) {
      onSearch(debouncedTerm)
    }
  }, [debouncedTerm, onSearch])

  return (
    <input
      value={term}
      onChange={(e) => setTerm(e.target.value)}
      placeholder="Search..."
    />
  )
}

With lodash/debounce

import debounce from 'lodash/debounce'
import { useMemo } from 'react'

function SearchBox({ onSearch }) {
  const [term, setTerm] = useState('')

  const debouncedSearch = useMemo(
    () => debounce((value: string) => onSearch(value), 300),
    [onSearch]
  )

  const handleChange = (e) => {
    setTerm(e.target.value)
    debouncedSearch(e.target.value)
  }

  return <input value={term} onChange={handleChange} />
}

Step 9: List Virtualization

Before: Render Everything

// BAD: 10,000 items = 10,000 DOM nodes
function ProductList({ products }) {
  return (
    <div className="list">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

After: With react-window

npm install react-window
import { FixedSizeList as List } from 'react-window'

function VirtualizedProductList({ products }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductCard product={products[index]} />
    </div>
  )

  return (
    <List
      height={600}
      itemCount={products.length}
      itemSize={120} // Height of each item
      width="100%"
    >
      {Row}
    </List>
  )
}

For Variable Height Lists

import { VariableSizeList as List } from 'react-window'

function VariableProductList({ products }) {
  const getItemSize = (index) => {
    // Calculate height based on content
    return products[index].description.length > 100 ? 180 : 120
  }

  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductCard product={products[index]} />
    </div>
  )

  return (
    <List
      height={600}
      itemCount={products.length}
      itemSize={getItemSize}
      width="100%"
    >
      {Row}
    </List>
  )
}

Impact

List of 10,000 products:

WITHOUT virtualization:
- DOM nodes: 10,000
- Memory: ~50MB
- Scroll: Laggy (15 FPS)
- Initial render time: 2-3s

WITH virtualization:
- DOM nodes: ~20 (visible + buffer)
- Memory: ~2MB
- Scroll: Smooth (60 FPS)
- Initial render time: <100ms

Step 10: API Caching

Before: Fetch on Every Mount

function ProductList() {
  const [products, setProducts] = useState([])

  // BAD: Fetch every time the component mounts
  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(setProducts)
  }, [])

  return <div>{/* ... */}</div>
}

After: With React Query/TanStack Query

npm install @tanstack/react-query
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      cacheTime: 30 * 60 * 1000, // 30 minutes
    },
  },
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ProductList />
    </QueryClientProvider>
  )
}

function ProductList() {
  const { data: products, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then(res => res.json()),
  })

  if (isLoading) return <Skeleton />
  if (error) return <Error message={error.message} />

  return <div>{/* cached products */}</div>
}

Manual Cache with SWR Pattern

// Simple in-memory cache
const cache = new Map()

async function fetchWithCache(url: string, ttl = 300000) {
  const cached = cache.get(url)

  if (cached && Date.now() - cached.timestamp < ttl) {
    return cached.data
  }

  const response = await fetch(url)
  const data = await response.json()

  cache.set(url, { data, timestamp: Date.now() })

  return data
}

// Usage
function ProductList() {
  const [products, setProducts] = useState([])

  useEffect(() => {
    fetchWithCache('/api/products', 5 * 60 * 1000)
      .then(setProducts)
  }, [])

  return <div>{/* ... */}</div>
}

Results: Before vs After

Lighthouse Scores

BEFORE:
=======
Performance: 35
LCP: 4.2s
FID: 320ms
CLS: 0.25
Bundle: 1.2MB

AFTER:
======
Performance: 92
LCP: 1.4s
FID: 45ms
CLS: 0.02
Bundle: 180KB

IMPROVEMENTS:
=============
Performance: +163% (35 -> 92)
LCP: -67% (4.2s -> 1.4s)
FID: -86% (320ms -> 45ms)
CLS: -92% (0.25 -> 0.02)
Bundle: -85% (1.2MB -> 180KB)

Real User Metrics

Perceived load time:
- Before: 5+ seconds
- After: <2 seconds

Interactivity:
- Before: App feels "stuck"
- After: Instant response

Scrolling lists:
- Before: 15 FPS, laggy
- After: 60 FPS, smooth

Performance Budgets

Setting Up Budgets

Create a budget.json file:

{
  "budgets": [
    {
      "resourceType": "script",
      "budget": 200
    },
    {
      "resourceType": "total",
      "budget": 500
    },
    {
      "metric": "first-contentful-paint",
      "budget": 1500
    },
    {
      "metric": "interactive",
      "budget": 3000
    },
    {
      "metric": "largest-contentful-paint",
      "budget": 2500
    }
  ]
}

Lighthouse CI in GitHub Actions

# .github/workflows/lighthouse.yml
name: Lighthouse CI

on: [push, pull_request]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci
      - run: npm run build

      - name: Run Lighthouse
        uses: treosh/lighthouse-ci-action@v10
        with:
          configPath: './lighthouserc.json'
          uploadArtifacts: true
          temporaryPublicStorage: true

lighthouserc.json

{
  "ci": {
    "collect": {
      "staticDistDir": "./dist"
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", {"minScore": 0.9}],
        "first-contentful-paint": ["warn", {"maxNumericValue": 1500}],
        "largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
        "total-blocking-time": ["warn", {"maxNumericValue": 300}],
        "cumulative-layout-shift": ["warn", {"maxNumericValue": 0.1}]
      }
    }
  }
}

Audit Checklist

Use this checklist for future audits:

Bundle and Code

  • Imports: Am I importing full libraries unnecessarily?
  • Code splitting: Do heavy components use lazy loading?
  • Tree shaking: Does the bundler remove unused code?
  • Minification: Are JS and CSS minified in production?

Images

  • Format: Am I using WebP/AVIF with fallbacks?
  • Size: Are images properly sized?
  • Lazy loading: Do below-the-fold images have loading="lazy"?
  • Dimensions: Do all images have width/height to avoid CLS?

Rendering

  • Memoization: Do heavy calculations use useMemo?
  • Callbacks: Do functions passed to children use useCallback?
  • Virtualization: Are long lists virtualized?
  • Debouncing: Do search inputs have debounce?

Network

  • Cache: Do API responses have appropriate cache?
  • Prefetch: Do critical resources use preload/prefetch?
  • Compression: Does the server use gzip/brotli?
  • CDN: Are static assets on a CDN?

Core Web Vitals

  • LCP < 2.5s: Does the largest element load quickly?
  • FID < 100ms: Does the page respond quickly to interactions?
  • CLS < 0.1: Are there no layout jumps?

Recommended Tools

ToolUse
LighthouseGeneral audit
WebPageTestDetailed analysis from multiple locations
Bundle AnalyzerSee what takes up space in the bundle
Chrome DevTools PerformanceDetailed profiling
React DevTools ProfilerReact-specific performance

Next Step

Apply this process to your own application:

  1. Run Lighthouse and note the initial score
  2. Identify the 3 biggest problems
  3. Apply fixes one by one
  4. Measure after each change
  5. Set up performance budgets to maintain improvements

Remember: measure first, optimize later. Don't optimize blindly.