Profiling & Debugging Performance
You can't fix what you can't measure. This guide covers the tools and techniques for finding performance bottlenecks in web applications—from Chrome DevTools deep dives to bundle analysis and real-user monitoring.
Chrome DevTools Performance Panel
The Performance panel records and visualizes everything happening in the browser: JavaScript execution, layout, paint, and more.
Recording a Performance Trace
- Open DevTools (F12 or Cmd+Opt+I)
- Go to the Performance tab
- Click the record button (or press Cmd+E)
- Interact with your page (load, click, scroll)
- Stop recording
For page load analysis:
- Click the reload button (circular arrow) instead of record
- This captures from navigation start through load
Throttle for Real-World Conditions
Click the gear icon in the Performance panel:
- CPU: 4x slowdown simulates mid-range mobile devices
- Network: Select "Slow 3G" or "Fast 3G"
Always profile with throttling enabled—your M3 MacBook Pro isn't your user's 3-year-old Android phone.
Reading the Flame Chart
The flame chart shows main thread activity over time:
┌─────────────────────────────────────────────────────────────┐
│ Frames [█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █] │
├─────────────────────────────────────────────────────────────┤
│ Main [Parse HTML──────][Evaluate Script────────────] │
│ └─[Parse CSS] └─[handleClick────────] │
│ └─[fetchData──] │
│ └─[renderComponent] │
├─────────────────────────────────────────────────────────────┤
│ Compositor [Paint][Composite] [Paint] │
└─────────────────────────────────────────────────────────────┘
Key elements:
| Color | Activity |
|---|---|
| Yellow | JavaScript execution |
| Purple | Layout (recalculating positions) |
| Green | Paint (drawing pixels) |
| Gray | System/idle |
What to look for:
- Long yellow bars: Heavy JavaScript, consider splitting or optimizing
- Purple bars during interaction: Forced synchronous layouts
- Red triangles: "Long tasks" (>50ms) that block the main thread
Identifying Long Tasks
Tasks over 50ms are "long tasks" that block user input:
// ❌ Long task: blocks main thread for 200ms
function processData(items) {
items.forEach(item => {
// Complex computation taking 0.2ms × 1000 items = 200ms
complexTransform(item);
});
}
// ✅ Chunked: yields to main thread
async function processData(items) {
const CHUNK_SIZE = 50;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(complexTransform);
// Yield to main thread
await new Promise(r => setTimeout(r, 0));
}
}
Click on a red triangle to see the call stack and identify the slow code.
Call Tree and Bottom-Up Views
Below the flame chart:
- Summary: Time breakdown by category (Scripting, Rendering, Painting, etc.)
- Bottom-Up: What functions took the most time (sorted by "Self Time")
- Call Tree: Hierarchical view of function calls
- Event Log: Chronological list of events
Bottom-Up is most useful for finding hotspots. Sort by "Self Time" to find the actual slow functions (not just their callers).
Forced Synchronous Layout
Reading layout properties immediately after changing them forces a layout:
// ❌ Forced synchronous layout (layout thrashing)
elements.forEach(el => {
el.style.width = `${container.offsetWidth}px`; // Read triggers layout
});
// ✅ Read first, write second
const containerWidth = container.offsetWidth; // Single read
elements.forEach(el => {
el.style.width = `${containerWidth}px`; // Writes only
});
In the Performance panel, forced layouts appear as purple bars with warning triangles. Click to see the triggering code.
Lighthouse
Lighthouse audits your page and provides actionable recommendations. It's built into Chrome DevTools and available as a CLI.
Running Lighthouse
In DevTools:
- Open DevTools → Lighthouse tab
- Select categories (Performance, Accessibility, Best Practices, SEO)
- Choose device (Mobile or Desktop)
- Click "Analyze page load"
CLI (more consistent results):
# Install globally
npm install -g lighthouse
# Run audit
lighthouse https://example.com --output html --output-path report.html
# Mobile simulation
lighthouse https://example.com --preset mobile
# Specific categories only
lighthouse https://example.com --only-categories=performance
Understanding the Report
Metrics section:
- FCP, LCP, TBT, CLS, Speed Index
- Each with a score: green (good), orange (needs improvement), red (poor)
Opportunities section:
- Specific, actionable fixes with estimated savings
- "Eliminate render-blocking resources" (-1.2s)
- "Properly size images" (-450KB)
Diagnostics section:
- Additional insights without specific savings estimates
- "Avoid long main-thread tasks"
- "Minimize third-party usage"
Lab Data vs. Field Data
Lighthouse provides lab data—synthetic measurements in controlled conditions.
Field data comes from real users via:
- Chrome User Experience Report (CrUX): Aggregated from Chrome users
- PageSpeed Insights: Shows both lab and field data side-by-side
- Search Console: Core Web Vitals report for your entire site
# PageSpeed Insights API (includes CrUX data)
curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://example.com&strategy=mobile"
Why both matter:
- Lab data is reproducible and great for development
- Field data reflects actual user experience (network, device variety)
- A page can score well in lab but poorly in field (or vice versa)
Bundle Analysis
Large JavaScript bundles hurt performance. Bundle analyzers visualize what's in your bundle.
Vite / Rollup: rollup-plugin-visualizer
pnpm add -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
filename: 'stats.html',
open: true,
gzipSize: true,
brotliSize: true,
}),
],
});
Run pnpm build and stats.html opens showing:
┌──────────────────────────────────────────────────────────────┐
│ Bundle Visualization │
│ ┌─────────────┐ ┌──────────┐ ┌─────────────────────────┐ │
│ │ react │ │ lodash │ │ your-code │ │
│ │ (42KB) │ │ (72KB) │ │ (150KB) │ │
│ │ │ │ │ │ ┌─────┐ ┌───────┐ │ │
│ │ │ │ │ │ │comp │ │ utils │ │ │
│ └─────────────┘ └──────────┘ └──┴─────┴─┴───────┴──────┘ │
└──────────────────────────────────────────────────────────────┘
What to look for:
- Unexpectedly large dependencies (72KB lodash when you use 1 function?)
- Duplicate dependencies (react appearing twice?)
- Dependencies that should be lazy-loaded (heavy charting library in main bundle?)
Webpack: webpack-bundle-analyzer
pnpm add -D webpack-bundle-analyzer
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
}),
],
};
source-map-explorer
Works with any bundler's source maps:
pnpm add -D source-map-explorer
# Analyze
npx source-map-explorer dist/assets/*.js
Produces a treemap showing exactly what's in each file.
Finding Unused Code
Coverage tab in DevTools:
- Open DevTools → Sources → Coverage (or Cmd+Shift+P → "Show Coverage")
- Click the reload button to start recording
- Interact with the page
- Review unused JavaScript/CSS (red = unused, green = used)
This shows runtime coverage—code that was never executed during your session. Great for finding:
- Unused library code (imported but never called)
- Dead code paths
- CSS for components that didn't render
Dependency Deep Dive
# What's importing that large dependency?
npx why-is-node-running
# Or use bundler-specific tools
npx vite-bundle-analyzer
# For npm dependencies
npx depcheck
Real User Monitoring (RUM)
Lab tools only show part of the picture. RUM captures metrics from actual user sessions.
web-vitals Library
// lib/vitals.ts
import { onLCP, onCLS, onINP, type Metric } from 'web-vitals';
function sendToAnalytics(metric: Metric) {
// Replace with your analytics endpoint
fetch('/api/vitals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
// Add user/session context
url: window.location.href,
timestamp: Date.now(),
}),
keepalive: true, // Survives page unload
});
}
// Report all Core Web Vitals
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
pnpm add web-vitals
Aggregate and Analyze
Store metrics and query for insights:
-- PostgreSQL example
-- 75th percentile LCP by page
SELECT
url_path,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75_lcp
FROM vitals
WHERE name = 'LCP'
AND created_at > NOW() - INTERVAL '7 days'
GROUP BY url_path
ORDER BY p75_lcp DESC;
-- Distribution of ratings
SELECT
name,
rating,
COUNT(*) as count,
COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (PARTITION BY name) as percentage
FROM vitals
GROUP BY name, rating;
Third-Party RUM Services
If you don't want to build your own:
- SpeedCurve: Synthetic + RUM, detailed visualizations
- Calibre: Performance budgets, CI integration
- Request Metrics: Simple, developer-focused
- Vercel Analytics: Built-in for Vercel deployments
- Cloudflare Web Analytics: Free, privacy-focused
Network Analysis
DevTools Network Panel
Key columns to enable (right-click headers):
- Initiator: What requested this resource
- Size: Transfer size (compressed) and resource size
- Time: Download duration and timing breakdown
Waterfall analysis:
- Click a request, go to "Timing" tab
- Long "Waiting (TTFB)"? Server is slow
- Long "Content Download"? Resource is too large or connection is slow
- Long "Stalled"? Too many connections or slow DNS
Request blocking:
- Network panel → right-click a request → "Block request URL"
- Reload to see impact
- Useful for measuring third-party script cost
Identifying Request Chains
Click "Initiator" column to trace dependency chains:
index.html
└── app.js
└── vendor.js
└── analytics.js
└── tracker.js
Long chains create waterfalls. Consider:
- Preloading critical resources
- Inlining small dependencies
- Lazy loading non-critical chains
Memory Profiling
Memory issues cause jank and crashes, especially on mobile.
Memory Panel in DevTools
Heap snapshots:
- DevTools → Memory → "Heap snapshot" → "Take snapshot"
- Interact with your app
- Take another snapshot
- Compare to find what's growing
Allocation timeline:
- Select "Allocation instrumentation on timeline"
- Start recording
- Interact with the page
- Stop and examine allocations
What to look for:
- Growing "Detached DOM tree" → DOM nodes being created but not garbage collected
- Large arrays or objects that keep growing
- Event listeners not being removed
Common Memory Leaks
Detached DOM elements:
// ❌ Leak: reference to removed element
const elements = [];
function addElement() {
const el = document.createElement('div');
document.body.appendChild(el);
elements.push(el); // Reference kept
}
function removeElement() {
document.body.removeChild(elements.pop()); // DOM removed, but array still references
}
// ✅ Fixed: no external reference
function addElement() {
const el = document.createElement('div');
el.dataset.myElement = 'true';
document.body.appendChild(el);
}
function removeElement() {
const el = document.querySelector('[data-my-element]');
el?.remove(); // No external reference to clean up
}
Forgotten event listeners:
// ❌ Leak: listener never removed
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
// ✅ Fixed: cleanup on unmount
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
Closures capturing large objects:
// ❌ Leak: closure captures entire largeData
function processData(largeData) {
const summary = computeSummary(largeData);
return function getSummary() {
console.log(largeData); // Keeps largeData alive!
return summary;
};
}
// ✅ Fixed: only capture what's needed
function processData(largeData) {
const summary = computeSummary(largeData);
// largeData not referenced in returned function
return function getSummary() {
return summary;
};
}
Performance APIs
Programmatically measure performance in your code:
Performance.mark() and measure()
// Mark start of operation
performance.mark('fetch-start');
const data = await fetch('/api/data').then(r => r.json());
// Mark end
performance.mark('fetch-end');
// Measure duration
performance.measure('fetch-duration', 'fetch-start', 'fetch-end');
// Get the measurement
const [measure] = performance.getEntriesByName('fetch-duration');
console.log(`Fetch took ${measure.duration}ms`);
PerformanceObserver
Watch for performance entries in real-time:
// Watch for long tasks
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn('Long task detected:', entry.duration, 'ms');
// Send to analytics
}
}
});
observer.observe({ type: 'longtask', buffered: true });
// Watch for layout shifts
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('Layout shift:', entry.value);
}
}
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
Navigation Timing
const [navigation] = performance.getEntriesByType('navigation');
console.log({
dnsLookup: navigation.domainLookupEnd - navigation.domainLookupStart,
tcpConnect: navigation.connectEnd - navigation.connectStart,
ttfb: navigation.responseStart - navigation.requestStart,
download: navigation.responseEnd - navigation.responseStart,
domParsing: navigation.domInteractive - navigation.responseEnd,
domComplete: navigation.domComplete - navigation.domInteractive,
});
Automated Performance Testing
Lighthouse CI
Run Lighthouse in CI pipelines:
pnpm add -D @lhci/cli
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: pnpm install
- run: pnpm build
- run: pnpm lhci autorun
// lighthouserc.js
module.exports = {
ci: {
collect: {
startServerCommand: 'pnpm preview',
url: ['http://localhost:4321/'],
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'first-contentful-paint': ['warn', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
};
Bundle Size Checks
Fail CI if bundle grows too large:
pnpm add -D bundlesize
// package.json
{
"bundlesize": [
{ "path": "dist/assets/*.js", "maxSize": "150 kB" },
{ "path": "dist/assets/*.css", "maxSize": "30 kB" }
],
"scripts": {
"check-size": "bundlesize"
}
}
Quick Reference: What Tool For What Problem
| Problem | Tool |
|---|---|
| Page loads slowly | Lighthouse, Network panel, Performance panel |
| Janky scrolling/animations | Performance panel (look for long tasks) |
| High JavaScript parse time | Coverage tab, Bundle analyzer |
| Memory growing over time | Memory panel (heap snapshots) |
| Layout shifts | DevTools Layout Shift Regions |
| Slow server response | Network panel TTFB, server logs |
| Third-party script impact | Network blocking, Coverage tab |
| Real user performance | web-vitals + RUM, CrUX data |
See Also
- Core Web Vitals - Metrics reference and optimization targets
- Loading Strategies - Code splitting to reduce bundle size
- Performance Overview - Budget setting and fundamentals
- Chrome DevTools Docs - Official documentation
- web.dev Performance - Google's performance guidance