renderpx

Performance Architecture

How to diagnose, measure, and fix the right performance problems.

The Problem

Performance problems in React apps come in three distinct flavors — and most developers treat them all the same way.

Network performance is about how much JavaScript the user has to download before anything happens. A 3MB bundle on a 4G connection takes 6+ seconds. The user hasn't even started rendering yet.

Render performance is about wasted React work. One state change at the top of your tree can trigger hundreds of unnecessary re-renders — and the user feels it as choppy interactions, janky typing, and sluggish filters.

Perceived performance is about psychology. A page that shows something in 100ms and fills in over the next 800ms feels faster than a page that shows nothing for 900ms, even if the total time is identical. Streaming and skeletons exploit this.

The trap

Optimizing without measuring. Developers add memo() everywhere, split every component into its own chunk, and virtualize every list — before checking if these are actually the bottlenecks. The result: added complexity, no measurable improvement.

The Performance Triangle

Three layers, different tools, different fixes. Most pages have problems in only one or two of these areas.

📦

Network

JS bundle size, number of requests, cache strategy

Symptoms

  • Slow initial load
  • High TTFB
  • Large bundle

Fixes

  • Code splitting
  • Dynamic imports
  • ISR/SSG
⚛️

Render

React re-renders, DOM mutations, expensive computations

Symptoms

  • Choppy interactions
  • Sluggish typing
  • Janky animations

Fixes

  • React.memo
  • State architecture
  • Virtualization
👁️

Perceived

Time until user sees something meaningful on screen

Symptoms

  • Blank white screen
  • Layout shifts
  • Spinner overload

Fixes

  • Streaming
  • Suspense
  • Skeleton loaders

The Framework: Measure, Identify, Fix

Every performance investigation follows the same three steps. The order matters — skipping straight to "fix" is how developers spend a week optimizing something that wasn't the bottleneck.

1

Measure first

Get a number. "It feels slow" is not actionable. "LCP is 4.2s on mobile" is.

Bundle analysisjs
Loading...
Web Vitals reportingtsx
Loading...
2

Identify the layer

Is the number in the Network, Render, or Perceived column? Each layer has different tools and different fixes.

High LCP with fast TTFB → Render layer (RSC/SSR missing, CSR waterfall)
High LCP with slow TTFB → Network layer (server slow, no caching)
Choppy interactions → Render layer (re-renders, expensive components)
Slow initial load on mobile → Network layer (bundle too large)
3

Apply the fix, re-measure

Apply one fix at a time. Re-run your measurement. If the number moved, the fix worked. If it didn't move, you fixed the wrong thing.

The goal is a measurable number improving — not "this code looks more optimized." Don't stop until you can show before/after metrics.

Decision Matrix

Match the symptom to the fix. Use the tool column to confirm the diagnosis before applying anything.

SymptomLikely causeFixConfirm with
Slow initial page loadLarge JS bundleCode splitting, dynamic importsBundle Analyzer
High LCP (> 2.5s)Content not in initial HTMLSSR/SSG/ISR instead of CSRLighthouse
Choppy UI when typingToo many re-rendersReact.memo, state architectureReact Profiler
Slow scrolling through long listsToo many DOM nodesVirtualization (react-window)DevTools Performance
Page feels slow to loadSingle slow query blocks everythingStreaming + SuspenseNetwork tab
High CLS scoreLayout shifts from dynamic contentSkeleton placeholders with correct dimensionsLighthouse
Slow after navigationRefetching data that hasn't changedReact Query / caching strategyNetwork tab
Images cause layout shiftNo dimensions on <img>width/height props, Next.js <Image>Lighthouse CLS

Re-renders in Action

The most common render performance issue: a state change in a parent component triggers re-renders in every child — even children that have no dependency on that state. Type in the input below to see the difference.

Type to trigger re-renders

Search input is in the parent. Watch the render counts below.

❌ Without memo()

All components re-render on every keystroke

Summary: 1 render

6 products · avg $76

ProductList: 1 render
Wireless Headphones$89
Mechanical Keyboard$145
Standing Desk Mat$62
USB-C Hub$48
Desk Lamp$34
Monitor Arm$78

✅ With memo()

ProductList & Summary skip re-renders — props didn't change

Summary: 1 render

6 products · avg $76

ProductList: 1 render
Wireless Headphones$89
Mechanical Keyboard$145
Standing Desk Mat$62
USB-C Hub$48
Desk Lamp$34
Monitor Arm$78
Renders highlighted in orange after 3+. The search query state lives in the parent — the product list and summary have no dependency on it.

React 19 Compiler

The React Compiler (stable in React 19) applies memo() semantics automatically. It analyzes your component's dependencies at the AST level and only re-renders what actually changed. On React 19+, you don't need to write memo() manually — the compiler does it for you, and does it more precisely than manual annotations.

Bundle Splitting in Action

Heavy libraries like chart tools and rich text editors are often loaded eagerly — even on pages where the user never opens them. Code splitting defers these to on-demand chunks that only download when actually needed.

Bundle impact of code splitting

What the user downloads on first load

main.js
85KB
react + react-dom
130KB
chart-library
180KB
rich-text-editor
210KB
date-picker
65KB
i18n strings
95KB
Total bundle:765KB

The "eager" total is what downloads on the user's first visit to any page. Deferred chunks only download when the user navigates to a feature that needs them. For a chart library used only in the Analytics section, most users never pay for it.

Progressive Examples

Five escalating optimization patterns for a product dashboard. Each example builds on the previous — you wouldn't apply all five simultaneously; you apply the ones your measurements point to.

Example 1: The Baseline

Essential

Understand what you're working with before optimizing anything

Before optimizing anything, you need a baseline. What does your bundle actually weigh? Where are the slow renders? Which routes cause layout shifts? Without measuring, you're guessing — and you'll optimize the wrong things.

tsx
Loading...

Why this works

Measurement is the first optimization. Without a baseline, you don't know if your changes actually improved anything — or made it worse. The three tools cover different layers: • Bundle Analyzer → network cost (JS to download) • Web Vitals → user-perceived performance (real timing) • React Profiler → render performance (wasted work) Run these before touching a single line of optimization code.

When this breaks

Measurement overhead in production. Web Vitals reporting adds a small amount of code. In most apps this is worth it, but for micro-optimized landing pages, use a sampling strategy — report only ~10% of sessions. React DevTools profiling is a development-only tool. Never ship with the profiler active.

Production Patterns

Using the React Profiler

React DevTools' Profiler is the correct tool for diagnosing render performance. It shows you exactly which components rendered, why they rendered, and how long they took — organized as a flamegraph.

React Profiler — programmatictsx
Loading...

Image Optimization

Images are often the single largest contributor to LCP. In Next.js, the <Image> component handles format conversion, resizing, lazy loading, and CLS prevention automatically.

Next.js Image optimizationtsx
Loading...

Font Optimization

Google Fonts loaded via <link> tags cause a third-party DNS lookup + stylesheet fetch before text can render. Next.js next/font downloads and self-hosts the font at build time — eliminating the network round-trip entirely.

Font loading strategytsx
Loading...

Continuous Monitoring

Performance regressions are silent. A dependency upgrade adds 50KB to your bundle. A new component re-renders on every keystroke. Without monitoring, you discover these in a user complaint six months later.

In CI

Use bundlesize or @next/bundle-analyzer with size budgets. Fail the build when a chunk exceeds a threshold. Treat bundle size as a first-class constraint, not an afterthought.

In production

Collect Web Vitals from real users (not just Lighthouse synthetic tests). Lighthouse measures a controlled environment. Real users have slow connections, old devices, and browser extensions. Field data tells a different story.

Common Mistakes & Hot Takes

🔥 memo() everywhere is not a performance strategy

memo() has overhead. The comparison function runs on every render. For cheap components, the comparison is more expensive than just re-rendering. Only memo() components that are demonstrably slow, or components that receive stable props but have an expensive render.

🔥 Lighthouse is a synthetic benchmark, not a user experience report

Lighthouse runs on a simulated throttled connection in a controlled Chrome instance. Your users have real connections, real devices, and real browser extensions. A perfect Lighthouse score doesn't mean your app is fast for real users. Field data (CrUX data, your own Web Vitals reporting) is ground truth.

🔥 Code splitting the wrong things slows you down

Splitting a 2KB utility into its own chunk adds more overhead (HTTP request, runtime chunk loading) than it saves. Split large third-party libraries (> 30KB) and routes you expect users won't visit. Don't split everything by default.

🔥 Choosing the right rendering strategy is worth more than any optimization

Switching a CSR page to ISR can take a 4-second LCP to 300ms — no memoization, no virtualization, no bundle splitting needed. Architecture-level decisions (where rendering happens) have higher leverage than code-level optimizations. Fix the architecture before micro-optimizing.

🔥 React Compiler makes manual memo() mostly obsolete

React 19's compiler automatically memoizes components and values based on actual dependency tracking — better than what humans write by hand. If you're starting a new project, use React 19 and skip writing memo/useCallback/useMemo for optimization purposes. You still use useMemo for semantics (stable identity), but not for performance.

A Real Rollout

What it looks like to diagnose a performance problem correctly — and why the fix is almost never what the team first assumes.

Context

E-commerce product listing page — React SPA, fully client-side rendered. The page ranked on Google. Core Web Vitals started failing in Search Console: LCP on mobile averaging 4.1s against a 2.5s green threshold. Four engineers on the frontend team, all aware of the problem, each with a different theory about the cause.

The problem

The team's instinct was to add memo() everywhere — it was the performance tool people knew. But running the React Profiler showed no unusually expensive renders. The problem wasn't in React at all. The main bundle was 2.1MB parsed and executed before a single pixel rendered. On a throttled mobile connection, the page showed a blank white screen for nearly three seconds. Google penalizes LCP, not render flamegraphs.

The call

Identified the bottleneck in the Network + Perceived layers, not the Render layer. Two decisions: switch product listing pages from CSR to SSG with ISR — the content was the same for all users, there was no reason to render it client-side on every request. And split the analytics library (400KB) and chart library (900KB) out of the main bundle — both were loaded eagerly on every page but only used in the /analytics admin route. The memo() work the team had originally planned: didn't do it.

How I ran it

The hardest conversation was with the analytics engineer, who pushed back on splitting the library: “if it's not loaded eagerly, we'll miss events on first page load.” Opened the bundle analyzer together. Showed that the 3KB analytics snippet — the part that fires events — was a separate entry point from the 400KB dashboard library (charts, tables) that had no business being on the product listing page. After the split, the snippet stayed eager; the dashboard code went on-demand. Also ran an A/B test on 10% of product listing pages using the SSG version before full rollout.

The outcome

LCP on product listing pages dropped from 4.1s to 0.8s after switching to SSG + ISR. Main bundle: 2.1MB → 380KB. Core Web Vitals went green in Search Console within 28 days. The memo() work the team had originally planned would have had zero measurable impact on LCP. The bottleneck was never in React — it was in the bundle size and rendering strategy decisions made years earlier when the app was small enough that neither mattered.