A

Profiling & Debugging Performance

performancedevtoolslighthouseprofilingdebuggingbundle-analysis

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

  1. Open DevTools (F12 or Cmd+Opt+I)
  2. Go to the Performance tab
  3. Click the record button (or press Cmd+E)
  4. Interact with your page (load, click, scroll)
  5. Stop recording

For page load analysis:

  1. Click the reload button (circular arrow) instead of record
  2. 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:

  1. Open DevTools → Lighthouse tab
  2. Select categories (Performance, Accessibility, Best Practices, SEO)
  3. Choose device (Mobile or Desktop)
  4. 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:

  1. Open DevTools → Sources → Coverage (or Cmd+Shift+P → "Show Coverage")
  2. Click the reload button to start recording
  3. Interact with the page
  4. 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:

  1. Network panel → right-click a request → "Block request URL"
  2. Reload to see impact
  3. 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:

  1. DevTools → Memory → "Heap snapshot" → "Take snapshot"
  2. Interact with your app
  3. Take another snapshot
  4. Compare to find what's growing

Allocation timeline:

  1. Select "Allocation instrumentation on timeline"
  2. Start recording
  3. Interact with the page
  4. 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 });
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

Last updated: March 23, 2026