← Home
15/06/25 · 8 min

From "feels slow" to measured wins

Performance profiling in React: from "feels slow" to measured wins

"The dashboard feels slow."

No video. No repro steps. No data. Just that sentence. This is how most performance work arrives — vague reports spanning anywhere from a 50ms blip to a 10-second hang.

Here's the workflow I use to turn those reports into measured, validated wins.

From "it's slow" to a reproducible baseline

Step one: get a repro and a number.

Reproduce the slow path

Pin it down:

  • Which page? What data was loaded?
  • What action triggered it — initial load, click, scroll?
  • What device and browser?

"Slow" isn't one problem. Slow initial load is different from janky interactions. A dashboard fine at 50 rows and broken at 5,000 is different from one slow on first render. Nail down which one you're chasing.

Capture a baseline

Three tools, depending on what I'm investigating:

  1. Chrome DevTools Performance panel — Record a trace. The flame chart shows where time goes: scripting, rendering, painting, or idle.

  2. React DevTools Profiler — Component render times and re-render triggers. The first place I look for React-specific issues.

  3. performance.mark() / performance.measure() — Wrap a specific interaction:

performance.mark('filter-start');
applyFilters(data);
performance.mark('filter-end');
performance.measure('filter-duration', 'filter-start', 'filter-end');

Write down the number. "Dashboard initial render: 2,400ms." "Filter interaction: 800ms." You can't prove you fixed anything without a baseline.

What to measure

Load performance

  • LCP — Main content visible (primary).
  • CLS — Layout stability (primary).
  • FCP — First pixels (diagnostic).
  • TTFB — Server response time (diagnostic — slow TTFB often explains slow LCP).
  • TBT — Lab-only proxy for interactivity issues during load.

Core Web Vitals are evaluated at the 75th percentile, segmented by mobile vs. desktop. Check the Network panel for large bundles and slow APIs. Check the Performance panel for long tasks.

Interaction performance

  • INP — Field metric: time from click/keypress to visual feedback. Lab runs can't measure it without real user input.
  • TBT — Lab proxy for INP: time spent in long tasks.

In the React Profiler, look for components rendering when they shouldn't, high self-time, and cascading re-renders from a single state change.

Memory

Heap snapshots in DevTools Memory panel. Compare before and after user actions. Watch for detached DOM nodes and growing arrays. I once found a listener leak that crawled the app to a halt after 20 minutes — the cleanup wasn't firing on unmount.

Techniques that move the needle

Once you know what's slow, here's what I reach for.

Code-splitting

If your bundle blocks initial render, split it.

const HeavyChart = lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <Suspense fallback={<ChartSkeleton />}>
      <HeavyChart data={data} />
    </Suspense>
  );
}

Code-splitting helps when:

  • You have features not everyone uses (admin panels, export tools)
  • You have heavy dependencies used in specific routes (charting libraries, editors)
  • Initial bundle is over 200–300KB of JavaScript (compressed)

It doesn't help if users always need the code immediately anyway—you're just trading one wait for another.

Memoization

React.memo, useMemo, and useCallback prevent unnecessary work. But they're not free—they add memory overhead and comparison costs.

Use memoization when:

  • A component re-renders often but its props rarely change
  • You're computing derived data that's expensive to recalculate
  • You're passing callbacks to memoized children
// Memoize expensive filtering
const filteredData = useMemo(
  () => data.filter(item => item.status === status),
  [data, status]
);

// Memoize a component that renders often with same props
const DataRow = memo(function DataRow({ item, onSelect }) {
  return (/* ... */);
});

Don't scatter memo() everywhere hoping it helps. Profile first, identify which re-renders are actually expensive, then memoize those.

Virtualization

If you're rendering hundreds or thousands of items, virtualize.

import { FixedSizeList } from 'react-window';

function VirtualList({ items }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
    >
      {({ index, style }) => (
        <div style={style}>{items[index].name}</div>
      )}
    </FixedSizeList>
  );
}

Virtualization helps when:

  • You have lists or tables with 100+ items
  • Each item isn't trivial to render
  • Users don't need all items visible simultaneously

It adds complexity (scroll position management, dynamic heights, keyboard navigation), so don't reach for it until you've confirmed rendering is the bottleneck.

Debouncing and throttling

For interactions that fire rapidly (typing, scrolling, resizing):

import debounce from 'lodash.debounce';
import { useCallback, useMemo, useEffect } from 'react';

const fetchResults = useCallback((query) => {
  // ...
}, []);

const debouncedSearch = useMemo(
  () => debounce((query) => fetchResults(query), 300),
  [fetchResults]
);

useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]);

This is often the fix for "typing in the search box is laggy"—the app was fetching or filtering on every keystroke. Keep fetchResults stable (e.g. via useCallback) so the debounced function doesn't call a stale closure; cancel on unmount so you don't get late state updates after the component is gone.

Moving work off the main thread

For truly expensive computations, consider Web Workers. Worker wiring depends on your bundler; a copy-paste-safe pattern with Vite/webpack-style resolution. Create the worker once and terminate on unmount so you don't leak it:

import { useMemo, useEffect } from 'react';

const worker = useMemo(
  () => new Worker(new URL('./filterWorker.js', import.meta.url), { type: 'module' }),
  []
);

useEffect(() => () => worker.terminate(), [worker]);

useEffect(() => {
  worker.onmessage = (e) => setFilteredData(e.data);
  return () => { worker.onmessage = null; };
}, [worker]);

// When you have data to process:
worker.postMessage({ data, filters });

I use this sparingly—it adds complexity and data serialization costs—but it's valuable when you're processing large datasets and blocking the main thread.

Validating improvements

Measure again with the same methodology. Compare to your baseline.

Rules I follow:

  1. Measure multiple times. Performance varies. Take 3–5 measurements, use the median.

  2. Test with realistic data. A dashboard fast at 10 rows might crumble at 10,000. Use production-scale data.

  3. Test on realistic devices. An M3 MacBook Pro proves nothing. DevTools CPU throttling at 4–6x slowdown, or test on actual mid-range hardware.

  4. Disable extensions. They skew results.

If your change didn't move the number, revert it. No attachment to the fix — only to the outcome.

Closing the loop: RUM and Web Vitals

Local profiling tells you what's slow on your machine. Real User Monitoring tells you what's slow for actual users. Both matter.

Collecting Web Vitals

Use the web-vitals library. Send name, value, and id per metric so you can dedupe, aggregate, and compute percentiles. The library uses PerformanceObserver with buffered: true, so you can defer loading it.

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

function sendToAnalytics(metric, context = {}) {
  const payload = {
    name: metric.name,
    value: metric.value,
    id: metric.id,
    ...context,
  };

  const body = new Blob([JSON.stringify(payload)], { type: 'application/json' });

  (navigator.sendBeacon && navigator.sendBeacon('/analytics', body)) ||
    fetch('/analytics', { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, keepalive: true });
}

onLCP(m => sendToAnalytics(m, { page: location.pathname }));
onINP(m => sendToAnalytics(m, { page: location.pathname }));
onCLS(m => sendToAnalytics(m, { page: location.pathname }));

What to track

Minimum: LCP, INP, CLS. Beyond that, custom timing for critical interactions — filter-to-results, page-load-to-interactive, modal-open-with-complex-content.

Using RUM data

RUM shows you P50, P75, P95 — median experience vs. worst 5%. It segments mobile from desktop. It catches regressions from last Tuesday's deploy.

I've had cases where local numbers looked great but RUM revealed users on slow connections had a terrible experience. RUM keeps you honest.

Before and after

When you ship a perf improvement: note RUM before (P75 LCP: 3.2s), ship, wait for stable sample size, compare (P75 LCP: 2.1s). Lab measurements are for debugging. RUM is the verdict.

The workflow

  1. Get a repro — specific steps, not "it's slow"
  2. Measure a baseline — DevTools, React Profiler, or custom marks
  3. Identify the bottleneck — load, interaction, memory? which component?
  4. Apply targeted fixes — code-splitting, memo, virtualization, debouncing — based on what you found, not a checklist
  5. Validate locally — did the number move?
  6. Ship and monitor RUM — did real users benefit?

The thread through all of it: numbers. "Feels faster" isn't a result. Measured before and after, lab and field — that's how you know you fixed something real.