How to Use This Checklist
This checklist is structured as a performance audit workflow. Start from the top, work through each section, and track which items pass and which need fixing. Each item includes the why, how to check, and how to fix.
Performance Audit Workflow:
+----------------+ +----------------+ +----------------+
| 1. Measure | --> | 2. Identify | --> | 3. Fix |
| (baseline) | | (bottlenecks) | | (optimizations)|
+----------------+ +----------------+ +----------------+
^ |
| |
+---------------------------------------------+
4. Re-measure & repeat
Section 1: Measurement & Baseline
Before optimizing, establish where you stand. Never optimize without data.
Run Lighthouse Audit
# Install Lighthouse
npm install -g lighthouse
# Run against production or preview URL
lighthouse https://your-site.com --output html --output-path ./audit.html
# Run against localhost for development
lighthouse http://localhost:3000 --output json --output-path ./audit.json
// Capture baseline scores programmatically
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
async function captureBaseline(url) {
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const { lhr } = await lighthouse(url, {
port: chrome.port,
onlyCategories: ['performance'],
});
await chrome.kill();
const baseline = {
date: new Date().toISOString(),
url,
score: Math.round(lhr.categories.performance.score * 100),
lcp: Math.round(lhr.audits['largest-contentful-paint'].numericValue),
cls: parseFloat(lhr.audits['cumulative-layout-shift'].numericValue.toFixed(3)),
tbt: Math.round(lhr.audits['total-blocking-time'].numericValue),
fcp: Math.round(lhr.audits['first-contentful-paint'].numericValue),
si: Math.round(lhr.audits['speed-index'].numericValue),
};
console.log('Baseline captured:', baseline);
return baseline;
}
Record Key Metrics
Your Baseline (fill in your numbers):
+-------------------------+---------+---------+---------+
| Metric | Current | Target | Status |
+-------------------------+---------+---------+---------+
| Performance Score | __/100 | >= 90 | [ ] |
| LCP (Largest Content) | ____ms | < 2500 | [ ] |
| CLS (Layout Shift) | ____ | < 0.1 | [ ] |
| TBT (Total Blocking) | ____ms | < 300 | [ ] |
| FCP (First Content) | ____ms | < 1800 | [ ] |
| TTFB (Time to 1st Byte) | ____ms | < 800 | [ ] |
| Total JS Size | ____KB | < 300 | [ ] |
| Total Page Weight | ____KB | < 1500 | [ ] |
| Number of Requests | ____ | < 50 | [ ] |
+-------------------------+---------+---------+---------+
Section 2: JavaScript Optimization
JavaScript is usually the biggest performance bottleneck. Each KB must be downloaded, parsed, compiled, and executed.
Audit Bundle Size
# For Next.js projects
ANALYZE=true npm run build
# Check individual page sizes in build output
# Next.js shows this automatically:
# Route (pages) Size First Load JS
# ┌ / (ISR: 60 Seconds) 5.2 kB 89.5 kB
# ├ /about 1.8 kB 78.2 kB
# └ /blog/[slug] 3.4 kB 92.1 kB
Checklist Items
JavaScript Optimization Checklist:
[ ] Bundle size analyzed (webpack-bundle-analyzer or source-map-explorer)
[ ] No unused dependencies in package.json
[ ] Heavy libraries replaced with lighter alternatives:
[ ] moment -> dayjs or native Intl
[ ] lodash -> lodash-es with specific imports
[ ] axios -> native fetch
[ ] Code splitting implemented:
[ ] Route-based (automatic in Next.js)
[ ] Component-based (dynamic imports for heavy components)
[ ] Interaction-based (load on click/hover)
[ ] Tree shaking working:
[ ] Using ES modules (not CommonJS)
[ ] sideEffects: false in package.json
[ ] No barrel file re-exports pulling in unused code
[ ] Third-party scripts optimized:
[ ] Analytics loaded with async
[ ] Chat widgets use facade pattern
[ ] Ads loaded after page interactive
[ ] No render-blocking scripts in <head>
[ ] All scripts use defer or async
Before/After: Third-Party Script Optimization
// BEFORE: All scripts load on page load
// Total third-party JS: 350KB
function Layout({ children }) {
return (
<html>
<head>
<script src="https://widget.intercom.io/widget/xxx" /> {/* 200KB */}
<script src="https://www.googletagmanager.com/gtag/js" /> {/* 33KB */}
<script src="https://js.stripe.com/v3/" /> {/* 40KB */}
<script src="https://cdn.fullstory.com/s/fs.js" /> {/* 60KB */}
</head>
<body>{children}</body>
</html>
);
}
// AFTER: Strategic loading reduces initial JS to ~33KB
import Script from 'next/script';
function Layout({ children }) {
return (
<>
{/* Analytics: async, doesn't block anything */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXX"
strategy="afterInteractive"
/>
{/* Chat: facade pattern, loads on interaction */}
<ChatWidgetFacade />
{/* Stripe: loads only on checkout page */}
{/* (loaded in CheckoutPage component, not globally) */}
{/* FullStory: loads during idle time */}
<Script
src="https://cdn.fullstory.com/s/fs.js"
strategy="lazyOnload"
/>
{children}
</>
);
}
Section 3: Image Optimization
Images are typically 50% of page weight. Optimizing them has the highest return on effort.
Checklist Items
Image Optimization Checklist:
[ ] All images use modern formats (WebP/AVIF with fallbacks)
[ ] All images have explicit width and height attributes
[ ] Hero/LCP image uses priority loading:
[ ] fetchpriority="high" or next/image priority prop
[ ] Preloaded in <head>
[ ] Below-fold images use loading="lazy"
[ ] Responsive images use srcset and sizes
[ ] No images larger than needed for display size
[ ] SVG used for icons, logos, and illustrations
[ ] Image CDN configured (or next/image)
[ ] No uncompressed images in /public
[ ] Aspect ratios preserved (no CLS from images)
Image Audit Script
// scripts/audit-images.js
// Find unoptimized images in your project
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
async function auditImages(dir) {
const issues = [];
const files = getAllFiles(dir, ['.jpg', '.jpeg', '.png', '.gif', '.webp']);
for (const file of files) {
const stats = fs.statSync(file);
const metadata = await sharp(file).metadata();
// Check file size
if (stats.size > 500 * 1024) {
issues.push({
file,
issue: 'TOO_LARGE',
detail: `${(stats.size / 1024).toFixed(0)}KB (max 500KB)`,
});
}
// Check dimensions
if (metadata.width > 2000 || metadata.height > 2000) {
issues.push({
file,
issue: 'TOO_WIDE',
detail: `${metadata.width}x${metadata.height} (max 2000px)`,
});
}
// Check format
if (['jpeg', 'png'].includes(metadata.format)) {
issues.push({
file,
issue: 'LEGACY_FORMAT',
detail: `${metadata.format} — convert to WebP/AVIF`,
});
}
}
return issues;
}
function getAllFiles(dir, extensions) {
const results = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...getAllFiles(fullPath, extensions));
} else if (extensions.some(ext => entry.name.endsWith(ext))) {
results.push(fullPath);
}
}
return results;
}
auditImages('./public').then(issues => {
if (issues.length === 0) {
console.log('All images pass audit.');
} else {
console.log(`Found ${issues.length} image issues:`);
issues.forEach(i => console.log(` ${i.issue}: ${i.file} — ${i.detail}`));
process.exit(1);
}
});
Section 4: CSS & Rendering
Checklist Items
CSS & Rendering Checklist:
[ ] Critical CSS inlined in <head>
[ ] Non-critical CSS deferred
[ ] No render-blocking stylesheets
[ ] font-display set on all @font-face rules
[ ] Fonts preloaded with <link rel="preload">
[ ] Font size-adjust configured to prevent CLS
[ ] CSS animations use transform/opacity (GPU-accelerated)
[ ] No layout thrashing (batch DOM reads/writes)
[ ] CSS file size reasonable (< 50KB compressed)
[ ] Unused CSS removed
Finding Unused CSS
// Using Chrome DevTools Coverage tab:
// 1. Open DevTools -> Coverage tab (Ctrl+Shift+P -> "Show Coverage")
// 2. Click reload to start recording
// 3. Navigate the site
// 4. Check % of unused CSS
// Programmatic unused CSS detection with PurgeCSS
// postcss.config.js
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
// PurgeCSS removes unused styles in production
...(process.env.NODE_ENV === 'production'
? [
require('@fullhuman/postcss-purgecss')({
content: ['./pages/**/*.tsx', './components/**/*.tsx'],
defaultExtractor: (content) =>
content.match(/[\w-/:]+(?<!:)/g) || [],
}),
]
: []),
],
};
CSS Animation Performance
/* Bad: Triggers layout recalculation on every frame */
.animate-bad {
transition: left 0.3s, top 0.3s, width 0.3s;
}
/* Good: Uses compositor-only properties (GPU-accelerated) */
.animate-good {
transition: transform 0.3s, opacity 0.3s;
}
/*
Properties that trigger layout (AVOID animating):
width, height, padding, margin, top, left, right, bottom
Properties that trigger paint only:
color, background-color, box-shadow, border-color
Properties handled by compositor (FAST):
transform, opacity, filter (some)
*/
/* Example: Slide in without layout recalc */
.slide-in {
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.slide-in.active {
transform: translateX(0);
}
Section 5: Loading & Network
Checklist Items
Loading & Network Checklist:
[ ] Resource hints configured:
[ ] preconnect for critical third-party origins (3-5 max)
[ ] preload for LCP image, critical fonts, and critical scripts
[ ] prefetch for likely next navigation
[ ] HTTP/2 or HTTP/3 enabled on server
[ ] Brotli compression enabled (fallback to gzip)
[ ] Proper Cache-Control headers:
[ ] Hashed assets: immutable, max-age=31536000
[ ] HTML: no-cache or short max-age
[ ] API: appropriate TTL with stale-while-revalidate
[ ] Service worker for offline capability (if applicable)
[ ] CDN configured for static assets
[ ] No unnecessary redirects
[ ] TTFB < 800ms
Compression Check
// scripts/check-compression.js
async function checkCompression(url) {
// Request with Accept-Encoding
const response = await fetch(url, {
headers: { 'Accept-Encoding': 'br, gzip, deflate' },
});
const encoding = response.headers.get('content-encoding');
const contentLength = response.headers.get('content-length');
console.log(`URL: ${url}`);
console.log(`Encoding: ${encoding || 'NONE (not compressed!)'}`);
console.log(`Size: ${contentLength ? (contentLength / 1024).toFixed(1) + 'KB' : 'chunked'}`);
if (!encoding) {
console.warn('WARNING: No compression! Enable Brotli or gzip.');
}
}
// Check key resources
checkCompression('https://example.com/');
checkCompression('https://example.com/_next/static/chunks/main.js');
checkCompression('https://example.com/styles/main.css');
Compression Comparison
+---------+-----------+------------+---------------------------+
| Method | Ratio | Speed | Support |
+---------+-----------+------------+---------------------------+
| None | 1x | N/A | N/A |
| gzip | 3-5x | Fast | Universal (99%+) |
| Brotli | 4-6x | Slower | Modern browsers (97%+) |
+---------+-----------+------------+---------------------------+
Example: 500KB JavaScript file
Uncompressed: 500KB
gzip: ~120KB (76% reduction)
Brotli: ~95KB (81% reduction)
Savings: 25KB less than gzip
Section 6: Layout Stability (CLS)
Checklist Items
Layout Stability Checklist:
[ ] All images have width and height (or aspect-ratio)
[ ] All videos have width and height
[ ] All embeds/iframes have width and height
[ ] No dynamically injected content above the fold without reserved space
[ ] Ad containers have min-height reserved
[ ] Web fonts use font-display: swap/optional with size-adjust
[ ] No FOUT (flash of unstyled text) causing visible reflow
[ ] Animations use transform instead of layout properties
[ ] No top banners/alerts that push content down
CLS Debugging
// Paste this in the browser console to find CLS sources
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // Ignore user-triggered shifts
console.group(`Layout shift: ${entry.value.toFixed(4)}`);
for (const source of entry.sources || []) {
console.log('Element:', source.node);
console.log('From:', source.previousRect);
console.log('To:', source.currentRect);
// Highlight the element
if (source.node) {
source.node.style.outline = '3px solid red';
setTimeout(() => {
source.node.style.outline = '';
}, 3000);
}
}
console.groupEnd();
}
}).observe({ type: 'layout-shift', buffered: true });
Section 7: Server & Infrastructure
Checklist Items
Server & Infrastructure Checklist:
[ ] TTFB < 800ms for all pages
[ ] CDN configured (Cloudflare, Vercel Edge, AWS CloudFront)
[ ] Static pages pre-rendered (SSG/ISR) where possible
[ ] Database queries optimized (no N+1, proper indexes)
[ ] API responses cached where appropriate
[ ] Server in region close to majority of users
[ ] HTTP/2 enabled
[ ] TLS 1.3 enabled (faster handshake)
[ ] DNS response time < 50ms
Section 8: Runtime Performance
Checklist Items
Runtime Performance Checklist:
[ ] No memory leaks (event listeners cleaned up, intervals cleared)
[ ] useEffect cleanup functions properly implemented
[ ] React.memo used for expensive list items
[ ] useMemo/useCallback used where re-renders are costly
[ ] No unnecessary re-renders (check with React DevTools Profiler)
[ ] Virtual scrolling for lists > 100 items
[ ] Debounced/throttled scroll and resize handlers
[ ] No main-thread-blocking computations
[ ] Web Workers for heavy calculations (if applicable)
[ ] requestAnimationFrame for visual updates
Memory Leak Detection
// Common React memory leaks and fixes
// LEAK 1: Uncleared intervals
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
// FIX: Clear interval on unmount
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}
// LEAK 2: Unremoved event listeners
function ScrollTracker() {
useEffect(() => {
const handler = () => console.log(window.scrollY);
window.addEventListener('scroll', handler);
// FIX: Remove listener on unmount
return () => window.removeEventListener('scroll', handler);
}, []);
return null;
}
// LEAK 3: Unaborted fetch requests
function DataFetcher({ id }) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/data/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
// FIX: Abort fetch on unmount or id change
return () => controller.abort();
}, [id]);
return data ? <div>{data.name}</div> : <p>Loading...</p>;
}
Debounce and Throttle
// Debounce: Wait until user stops typing
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// Throttle: Run at most once per interval
function throttle(fn, interval) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
// Usage in React
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedSearch = useMemo(
() => debounce((q) => fetchResults(q), 300),
[]
);
const handleChange = (e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value);
};
return <input value={query} onChange={handleChange} />;
}
function InfiniteScrollList() {
const throttledLoad = useMemo(
() => throttle(() => loadMore(), 200),
[]
);
useEffect(() => {
window.addEventListener('scroll', throttledLoad);
return () => window.removeEventListener('scroll', throttledLoad);
}, [throttledLoad]);
return <div>{/* list items */}</div>;
}
Section 9: SEO & Accessibility Performance
Checklist Items
SEO & Accessibility Performance Checklist:
[ ] Page titles unique and descriptive (< 60 chars)
[ ] Meta descriptions present (< 160 chars)
[ ] Open Graph tags set (og:title, og:description, og:image)
[ ] Canonical URLs prevent duplicate indexing
[ ] HTML is semantically correct (h1-h6 hierarchy, landmarks)
[ ] Images have alt text
[ ] Lighthouse Accessibility score >= 90
[ ] No janky scrolling or slow interactions
[ ] Focus management works for keyboard users
[ ] ARIA labels on interactive elements
Before/After Case Study: E-Commerce Product Page
Before Optimization
Lighthouse Score: 42/100
Metrics:
LCP: 4.8s (hero image: 2MB unoptimized JPEG)
CLS: 0.32 (ads injected, no image dimensions)
TBT: 1200ms (moment.js + lodash + full icon library)
FCP: 3.2s (render-blocking CSS + JS)
TTFB: 1.1s (no caching, SSR with blocking DB queries)
Bundle: 780KB gzipped
moment: 72KB
lodash: 70KB
react-icons (all): 45KB
recharts: 150KB
axios: 13KB
App code: 430KB
Resources: 92 requests, 4.2MB total
45 unoptimized images
12 third-party scripts
3 render-blocking stylesheets
Optimizations Applied
1. Images
[x] Converted to WebP/AVIF
[x] Added width/height to all <img>
[x] Hero image: priority + preload
[x] Below-fold: loading="lazy"
Result: 4.2MB -> 800KB images (-81%)
2. JavaScript
[x] moment -> dayjs (-70KB)
[x] lodash -> lodash-es targeted imports (-65KB)
[x] react-icons -> inline SVGs (-43KB)
[x] recharts -> dynamic import (-150KB from initial)
[x] axios -> fetch (-13KB)
Result: 780KB -> 210KB bundle (-73%)
3. CSS & Fonts
[x] Inlined critical CSS
[x] Deferred non-critical CSS
[x] font-display: swap + size-adjust
[x] Self-hosted fonts (removed Google Fonts connection)
Result: FCP improved by 1.4s
4. Loading
[x] preconnect to CDN origin
[x] preload hero image + primary font
[x] defer all scripts
[x] async for analytics
Result: Network waterfall 40% shorter
5. Server
[x] ISR instead of SSR (static with revalidation)
[x] CDN caching with stale-while-revalidate
[x] Database query optimization (JOIN instead of N+1)
Result: TTFB 1100ms -> 200ms
6. Layout Stability
[x] Reserved ad container heights
[x] Image dimensions on all images
[x] Font size-adjust for fallback
Result: CLS 0.32 -> 0.02
After Optimization
Lighthouse Score: 96/100
Metrics:
LCP: 1.4s (was 4.8s, -71%)
CLS: 0.02 (was 0.32, -94%)
TBT: 120ms (was 1200ms, -90%)
FCP: 0.8s (was 3.2s, -75%)
TTFB: 200ms (was 1100ms, -82%)
Bundle: 210KB gzipped (was 780KB, -73%)
Resources: 28 requests, 650KB total (was 92 requests, 4.2MB)
Continuous Improvement Workflow
Performance is not a one-time fix. It degrades naturally as features are added.
Automated Performance Gate
# .github/workflows/performance-gate.yml
name: Performance Gate
on: [pull_request]
jobs:
performance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci && npm run build
# Bundle size check
- name: Bundle budget
run: node scripts/check-bundle-size.js
# Lighthouse check
- name: Lighthouse
run: |
npm start &
npx wait-on http://localhost:3000
npx @lhci/cli autorun
# Image audit
- name: Image audit
run: node scripts/audit-images.js
Weekly Performance Review
// scripts/weekly-report.js
// Run weekly to track trends
async function weeklyReport() {
const history = JSON.parse(fs.readFileSync('./budget-history.json'));
const lastWeek = history.filter(
r => new Date(r.timestamp) > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
);
const latest = lastWeek[lastWeek.length - 1];
const oldest = lastWeek[0];
console.log('Weekly Performance Report');
console.log('========================');
console.log(`Bundle: ${(latest.totalJS / 1024).toFixed(0)}KB`);
console.log(`Change: ${((latest.totalJS - oldest.totalJS) / 1024).toFixed(1)}KB`);
console.log(`Trend: ${latest.totalJS > oldest.totalJS ? 'GROWING (investigate)' : 'Stable or shrinking'}`);
}
weeklyReport();
Performance Regression Playbook
When performance regresses:
1. IDENTIFY — Which metric regressed?
- Run Lighthouse, compare with previous build
- Check bundle analyzer for new/larger chunks
2. ISOLATE — What changed?
- git bisect to find the commit that introduced the regression
- Check recent PRs for new dependencies or large images
3. MEASURE — How bad is it?
- Quantify the impact (e.g., "LCP increased from 2.1s to 3.4s")
- Check if it exceeds performance budget thresholds
4. FIX — Apply the appropriate optimization
- New dependency too large? -> Find lighter alternative or lazy load
- New image unoptimized? -> Compress and resize
- New feature blocking main thread? -> Code split or defer
5. VERIFY — Confirm the fix
- Re-run Lighthouse, compare with pre-regression baseline
- Deploy and check RUM data after 24 hours
6. PREVENT — Add a guard
- Add specific assertion to LHCI config
- Add bundle budget for the affected page
- Add to PR checklist
Quick Reference: Priority Matrix
+----------------------------+--------+--------+
| Optimization | Impact | Effort |
+----------------------------+--------+--------+
| Compress images (WebP/AVIF)| HIGH | LOW | <-- Do first
| Add loading="lazy" | HIGH | LOW |
| Set image dimensions | HIGH | LOW |
| Defer JS/CSS | HIGH | LOW |
| Preload LCP image | HIGH | LOW |
| Replace heavy libraries | HIGH | MEDIUM |
| Code split heavy components| HIGH | MEDIUM |
| Inline critical CSS | MEDIUM | MEDIUM |
| Self-host fonts | MEDIUM | MEDIUM |
| Service worker | MEDIUM | HIGH |
| Virtual scrolling | MEDIUM | HIGH |
| CDN configuration | HIGH | HIGH |
| Database query optimization| HIGH | HIGH |
+----------------------------+--------+--------+
Start with HIGH impact + LOW effort items.
They give you the biggest wins for the least work.
Key Takeaways
- Always measure before optimizing — establish a baseline with Lighthouse
- Images are the easiest win: compress, resize, lazy load, set dimensions
- JavaScript is the biggest bottleneck: analyze, split, replace, defer
- CLS is often caused by missing image dimensions and injected content without reserved space
- Critical CSS inline + deferred non-critical CSS eliminates render blocking
- Resource hints (preconnect, preload, prefetch) reshape the network waterfall
- Performance budgets in CI prevent regressions from shipping
- RUM data tells you what real users experience — lab data alone is not enough
- Performance is continuous — build automated gates and review trends weekly
- Start with high-impact, low-effort items from the priority matrix
- Every optimization should be measured before and after to confirm impact