Skip to content

Frontend Profiling

Measure and optimize frontend performance.

Core Web Vitals

Metric Good Needs Work Poor
LCP (Largest Contentful Paint) < 2.5s 2.5-4s > 4s
FID (First Input Delay) < 100ms 100-300ms > 300ms
CLS (Cumulative Layout Shift) < 0.1 0.1-0.25 > 0.25
INP (Interaction to Next Paint) < 200ms 200-500ms > 500ms

Lighthouse

Chrome DevTools

  1. Open DevTools (F12)
  2. Go to Lighthouse tab
  3. Select categories and device
  4. Click "Analyze page load"

CLI

# Install
npm install -g lighthouse

# Run audit
lighthouse https://example.com --output html --output-path report.html

# CI-friendly JSON
lighthouse https://example.com --output json --chrome-flags="--headless"

Performance Budget

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: ['http://localhost:3000/'],
    },
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'total-blocking-time': ['error', { maxNumericValue: 300 }],
      },
    },
  },
};

React DevTools Profiler

Setup

  1. Install React DevTools browser extension
  2. Open DevTools → Profiler tab
  3. Click Record, interact with app, Stop

Reading Results

  • Flamegraph: Time per component
  • Ranked: Components sorted by render time
  • Component chart: Renders over time

Highlight Updates

Settings → Highlight updates when components render

Shows which components re-render and why.

Performance Tab

Recording

  1. Open DevTools → Performance tab
  2. Click Record
  3. Interact with page
  4. Stop recording

Key Metrics

  • Main Thread: JavaScript execution
  • FPS: Should stay at 60
  • Layout Shifts: CLS events
  • Long Tasks: > 50ms tasks (red)

Finding Issues

Long task (200ms)
├── Event: click
├── Function Call: handleClick
│   ├── Function Call: expensiveCalculation (150ms) ← Bottleneck
│   └── Layout
└── Paint

Bundle Analysis

source-map-explorer

# Build with source maps
bun run build

# Analyze
bunx source-map-explorer 'dist/**/*.js'

webpack-bundle-analyzer

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // config
});
ANALYZE=true bun run build

Network Analysis

Waterfall Chart

DevTools → Network tab

Look for: - Blocking requests - Large resources - Many requests - Slow TTFB

Request Breakdown

Phase What It Means
Queueing Waiting for connection
Stalled Blocked by other requests
DNS Lookup Resolving domain
Initial Connection TCP handshake
SSL TLS negotiation
TTFB Server processing
Content Download Data transfer

Memory Profiling

Taking Heap Snapshots

  1. DevTools → Memory tab
  2. Select "Heap snapshot"
  3. Take snapshot
  4. Use app
  5. Take another snapshot
  6. Compare for leaks

Finding Leaks

Objects allocated between Snapshot 1 and Snapshot 2
├── (array) +5000 (could be a leak)
├── HTMLDivElement +100 (DOM nodes not cleaned up)
└── EventListener +50 (missing cleanup)

React-Specific Profiling

Why Did You Render

bun add @welldone-software/why-did-you-render
// src/wdyr.js
import React from 'react';

if (process.env.NODE_ENV === 'development') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render');
  whyDidYouRender(React, {
    trackAllPureComponents: true,
  });
}
// On specific component
MyComponent.whyDidYouRender = true;

React.Profiler API

import { Profiler, ProfilerOnRenderCallback } from 'react';

const onRender: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) => {
  console.log(`${id} ${phase}: ${actualDuration.toFixed(2)}ms`);
};

function App() {
  return (
    <Profiler id="App" onRender={onRender}>
      <MainContent />
    </Profiler>
  );
}

Common Optimizations

Component Memoization

// Memoize expensive components
const MemoizedList = memo(ExpensiveList);

// Memoize callbacks
const handleClick = useCallback(() => {
  // ...
}, [dependencies]);

// Memoize computed values
const sortedItems = useMemo(
  () => items.sort((a, b) => a.name.localeCompare(b.name)),
  [items]
);

Code Splitting

// Route-based splitting
const Dashboard = lazy(() => import('./Dashboard'));

// Component-based splitting
const HeavyChart = lazy(() => import('./HeavyChart'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Dashboard />
    </Suspense>
  );
}

Image Optimization

// Next.js Image
import Image from 'next/image';

<Image
  src="/hero.jpg"
  width={1200}
  height={600}
  priority // For LCP images
  placeholder="blur"
/>

Virtual Lists

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualList({ items }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
  });

  return (
    <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: virtualItem.start,
              height: virtualItem.size,
            }}
          >
            {items[virtualItem.index].name}
          </div>
        ))}
      </div>
    </div>
  );
}

Performance Monitoring

Web Vitals Library

import { onCLS, onFID, onLCP, onINP } from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
  });
  navigator.sendBeacon('/analytics', body);
}

onCLS(sendToAnalytics);
onFID(sendToAnalytics);
onLCP(sendToAnalytics);
onINP(sendToAnalytics);

Performance Observer

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn('Long task:', entry);
    }
  }
});

observer.observe({ entryTypes: ['longtask'] });