Week 14: Profiling & Code-Splitting

Week 3 taught useMemo and useCallback from first principles, but applying them without measuring first is guessing. This week starts with the tool that tells you where time is actually going, then covers the highest-leverage performance win for most apps: not sending code to the browser until it's actually needed.

Module 10 of 13 Week 14 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Profile a render to find which components are actually slow
  • Code-split a route so its bundle loads only when visited
  • Read a bundle analysis to find what's actually bloating your app

1. React DevTools Profiler

Before optimizing anything, measure. The React DevTools browser extension includes a Profiler tab: click record, interact with your app, stop recording, and see exactly which components rendered, how long each took, and — critically — why each one rendered.

what the Profiler shows per commit
// For each component that rendered during the recording:
// - Render duration (ms)
// - "Why did this render?" -- props changed, state changed, parent re-rendered, context changed
// - A flame graph: wider bars = more time spent

The "why did this render" information is the most actionable part — it tells you directly whether a slow component is slow because of expensive work inside it (a candidate for useMemo, Week 3) or because it re-rendered unnecessarily due to a parent re-rendering with a fresh prop reference (a candidate for React.memo + useCallback, also Week 3).

Measure first, always

It's tempting to sprinkle useMemo everywhere "to be safe" — Week 3 already covered why that's a net negative for cheap computations. The Profiler turns "I think this might be slow" into "this component genuinely took 40ms and re-rendered 12 times it didn't need to," which is what should actually guide where you spend optimization effort.

2. React.memo, Revisited

Week 3 introduced useCallback in the context of React.memo without deep-diving memo itself. React.memo wraps a component so React skips re-rendering it when its props are shallowly equal to last time:

ProductCard.tsx
const ProductCard = React.memo(function ProductCard({ product }: { product: Product }) {
  console.log('rendering', product.name);
  return <div>{product.name} — ${product.price}</div>;
});

"Shallowly equal" means each individual prop is compared with Object.is — exactly the same comparison Week 2's dependency arrays use. This is precisely why a fresh object or function reference on every render (an inline handler, an inline array literal) defeats memo even when the data inside it is identical — the same lesson Week 3 covered for useCallback, now generalized: memoization only works when the things being compared are actually stable across renders.

A quick heuristic for when React.memo is worth adding: the component renders often relative to how often its actual props change, and rendering it is measurably not free — confirmed by the Profiler, not guessed at.

3. Route-Based Code-Splitting

By default, a bundler ships your entire app as one (or a few) JavaScript files — a user visiting the login page downloads the code for the admin dashboard too, even though they'll likely never see it. Code-splitting breaks the bundle into pieces loaded on demand.

before — everything in one bundle
import DashboardPage from './pages/DashboardPage';
import SettingsPage from './pages/SettingsPage';
import AdminPage from './pages/AdminPage'; // large, rarely visited -- shipped to everyone anyway

const router = createBrowserRouter([
  { path: '/dashboard', element: <DashboardPage /> },
  { path: '/settings', element: <SettingsPage /> },
  { path: '/admin', element: <AdminPage /> },
]);
after — AdminPage loads only when visited
import { lazy, Suspense } from 'react';
import DashboardPage from './pages/DashboardPage';
import SettingsPage from './pages/SettingsPage';

const AdminPage = lazy(() => import('./pages/AdminPage'));

const router = createBrowserRouter([
  { path: '/dashboard', element: <DashboardPage /> },
  { path: '/settings', element: <SettingsPage /> },
  {
    path: '/admin',
    element: (
      <Suspense fallback={<PageSkeleton />}>
        <AdminPage />
      </Suspense>
    ),
  },
]);

React.lazy(() => import('./pages/AdminPage')) tells the bundler to put AdminPage and everything it imports into a separate file, fetched only the first time a user actually navigates to /admin. This is the exact same Suspense component from Week 12 — lazy is a component that suspends while its chunk downloads, using the identical mechanism as a suspending data fetch.

4. Lazy-Loading Non-Route Code

The same pattern applies to anything heavy and conditionally shown, not just whole routes — a modal, a rich text editor, a charting library only used on one rarely-visited screen:

SettingsPage.tsx
import { lazy, Suspense, useState } from 'react';

const AvatarCropper = lazy(() => import('./AvatarCropper')); // pulls in a large image library

function SettingsPage() {
  const [isCropping, setIsCropping] = useState(false);

  return (
    <div>
      <button onClick={() => setIsCropping(true)}>Change avatar</button>
      {isCropping && (
        <Suspense fallback={<p>Loading editor...</p>}>
          <AvatarCropper />
        </Suspense>
      )}
    </div>
  );
}

Most users who visit SettingsPage never click "Change avatar" at all — lazy-loading AvatarCropper means its (potentially large) image-processing dependencies never download for those users, only for the ones who actually trigger it.

5. Bundle Analysis

To know what's actually worth splitting, you need to see what's in your bundle in the first place. rollup-plugin-visualizer (Vite is built on Rollup) generates an interactive treemap of your final bundle:

terminal
npm install -D rollup-plugin-visualizer
vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({ open: true, gzipSize: true }),
  ],
});

Running a production build (npm run build) now opens a treemap where box size represents each dependency's actual size in the final bundle. This routinely surfaces surprises: a date library pulled in wholesale for one formatting call, a charting library imported at the top level when only one rarely-visited page uses it — both are prime code-splitting candidates once you can actually see them.

Rendering performance vs. loading performance

Sections 1–2 (Profiler, memo) are about how fast components render once their code is already downloaded. Sections 3–5 are about how fast the code arrives in the first place. Both matter, but they're solved with completely different tools — don't reach for useMemo to fix a slow initial page load; that's a bundle-size problem.

6. Hands-on Exercise

Hands-on

Profile, split, and measure the difference

Find a real bottleneck, fix it two different ways, and prove the fix worked with actual measurements.

Requirements:

  1. Take an app from an earlier week (Week 3's 20,000-row product list is a good candidate) and record a profile of an interaction that feels sluggish using the React DevTools Profiler.
  2. Identify, from the profile's "why did this render" data, one component re-rendering unnecessarily, and fix it with React.memo + useCallback. Re-profile and confirm the fix in the flame graph.
  3. Add a second route to the app behind a heavy component (import a large library like a charting library, or just a component with a deliberately large inline array as filler) and code-split it with React.lazy.
  4. Run a production build with the bundle visualizer enabled, and confirm visually that the lazy-loaded route appears as a separate chunk, not part of the main bundle.
  5. Open your browser's Network tab, load the app, and confirm the heavy route's code only downloads when you actually navigate to it.
Hint

The Profiler only records while you're actively recording — click "Record," perform the exact interaction you're investigating, then stop. Recording your app's initial load or unrelated interactions will bury the signal you're actually looking for in noise.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What specific piece of information does the React DevTools Profiler give you that a general "this feels slow" hunch doesn't?

Exactly which components rendered, how long each took, and why each one rendered (props changed, state changed, parent re-rendered, context changed). That last part tells you whether a slow component needs useMemo for genuinely expensive work, or React.memo/useCallback because it's re-rendering unnecessarily — two different problems with two different fixes.

Q2

What comparison does React.memo use to decide whether to skip a re-render?

A shallow, per-prop Object.is comparison against the previous render's props — the same comparison used by dependency arrays and by useMemo/useCallback internally. That's why a fresh object or function reference passed as a prop on every render defeats the memoization, even if its contents are identical.

Q3

What relationship does React.lazy have to Week 12's Suspense?

They use the same underlying mechanism — a lazily-loaded component suspends (in the exact technical sense from Week 12) while its JavaScript chunk downloads, and the nearest wrapping Suspense boundary shows its fallback until that download completes. It's the same "not ready yet" signal Week 12 used for data fetching, applied here to loading code instead of loading data.

Q4

Would using useMemo more aggressively fix a slow initial page load caused by a large JavaScript bundle?

No. useMemo addresses render-time cost — how long a component takes to compute its output once its code is already downloaded and running. A slow initial load is a bundle-size/loading-time problem, solved by code-splitting (Sections 3–4) and understanding what's actually in the bundle (Section 5) — a completely different bottleneck requiring different tools.