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
- Open Chrome DevTools (F12)
- Go to the Lighthouse tab
- Select:
- Mode: Navigation
- Device: Mobile
- Categories: Performance, Best Practices, SEO
- 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
| Problem | Cause | Impact |
|---|---|---|
| High TBT | Expensive calculation blocking main thread | Bad FID |
| High LCP | Large images, blocking JS | Slow UX |
| High CLS | Images without dimensions | Visual jumps |
Step 2: DevTools Performance Tab
Recording a Session
- Open DevTools > Performance
- Click the record button (circle)
- Interact with the app (scroll, search, etc.)
- Click stop
- 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:
- expensiveCalculation() - 200ms+ blocking
- Moment.js parsing - 50ms+ on each render
- Product filtering - Grows with more products
Step 3: Network Waterfall
Analyzing Resource Loading
- DevTools > Network
- Reload the page (Ctrl+Shift+R for clean cache)
- 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
- Huge bundle: 800KB of JS in a single file
- Sequential loading: Images wait for JS
- No compression: Images in original format
- 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
| Tool | Use |
|---|---|
| Lighthouse | General audit |
| WebPageTest | Detailed analysis from multiple locations |
| Bundle Analyzer | See what takes up space in the bundle |
| Chrome DevTools Performance | Detailed profiling |
| React DevTools Profiler | React-specific performance |
Next Step
Apply this process to your own application:
- Run Lighthouse and note the initial score
- Identify the 3 biggest problems
- Apply fixes one by one
- Measure after each change
- Set up performance budgets to maintain improvements
Remember: measure first, optimize later. Don't optimize blindly.