Skip to main content

React Performance - Memoization

React.memo, useMemo, useCallback for optimization


Question 2: Code Splitting and Lazy Loading​

Difficulty: 🟑 Medium Frequency: ⭐⭐⭐⭐⭐ Time: 8 minutes Companies: All major companies

Question​

How do you implement code splitting and lazy loading in React?

Answer​

Code Splitting - Split your bundle into smaller chunks that load on demand. Lazy Loading - Load components only when needed.

Code Example​

import React, { lazy, Suspense } from 'react';

// 1. DYNAMIC IMPORT (Basic)
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
}

// 2. ROUTE-BASED SPLITTING
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}

// 3. CONDITIONAL LOADING
function FeatureFlag() {
const [showFeature, setShowFeature] = useState(false);
const Feature = lazy(() => import('./NewFeature'));

return (
<>
<button onClick={() => setShowFeature(true)}>
Show Feature
</button>
{showFeature && (
<Suspense fallback={<Spinner />}>
<Feature />
</Suspense>
)}
</>
);
}

// 4. ERROR BOUNDARY WITH SUSPENSE
class ErrorBoundary extends React.Component {
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

render() {
if (this.state.hasError) {
return <div>Failed to load component</div>;
}
return this.props.children;
}
}

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

Resources​


πŸ” Deep Dive: Code Splitting Implementation and Webpack Magic​

How Dynamic Imports Work Under the Hood:

When you use React.lazy(() => import('./Component')), several complex processes occur:

  1. Webpack Code Splitting: Webpack creates a separate chunk file (e.g., 3.chunk.js) containing the lazy component's code. The main bundle only includes a tiny wrapper that knows how to load this chunk.

  2. Dynamic Import Syntax: The import() function returns a Promise that resolves to the module. This is a JavaScript spec feature (Stage 4 proposal, widely supported) that enables async module loading.

  3. Network Request Flow: When React encounters a Suspense boundary with a lazy component, it triggers the dynamic import, which creates a <script> tag pointing to the chunk file, downloads it, executes the code, and resolves the Promise with the component.

  4. Suspense Integration: React.lazy integrates with Suspense by throwing a Promise while loading. When Suspense catches this Promise, it renders the fallback. Once resolved, React re-renders with the actual component.

Advanced Webpack Configuration for Optimal Splitting:

// webpack.config.js - Production optimization
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// Vendor libraries (rarely change)
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
},
// Common components (shared across routes)
common: {
minChunks: 2,
priority: 5,
reuseExistingChunk: true,
},
},
},
// Long-term caching with content hashes
moduleIds: 'deterministic',
runtimeChunk: 'single',
},
};

Magic Comments for Chunk Naming:

// Control chunk names for better debugging
const Dashboard = lazy(() =>
import(
/* webpackChunkName: "dashboard" */
/* webpackPrefetch: true */
'./Dashboard'
)
);

// Prefetch: Download during idle time
// Preload: Download immediately (parallel to parent)
const Critical = lazy(() =>
import(/* webpackPreload: true */ './Critical')
);

How Suspense Manages Multiple Lazy Components:

// Waterfall loading (BAD - sequential)
<Suspense fallback={<Spinner />}>
<LazyA />
<Suspense fallback={<Spinner />}>
<LazyB />
</Suspense>
</Suspense>

// Parallel loading (GOOD - simultaneous)
<Suspense fallback={<Spinner />}>
<LazyA />
<LazyB />
</Suspense>

Both components load in parallel and Suspense waits for ALL to resolve before rendering.

React 18 Concurrent Features Integration:

With React 18's Concurrent Rendering, Suspense can show stale content while loading new content, preventing jarring loading spinners:

import { useTransition } from 'react';

function Navigation() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('home');

const handleTabChange = (newTab) => {
startTransition(() => {
setTab(newTab); // Non-urgent update
});
};

return (
<div className={isPending ? 'loading' : ''}>
<Suspense fallback={<TabSkeleton />}>
{tab === 'home' && <HomeTab />}
{tab === 'profile' && <LazyProfileTab />}
</Suspense>
</div>
);
}

The old content stays visible while the new lazy component loads.

Bundle Size Analysis Tools:

# Webpack Bundle Analyzer - visualize bundle composition
npm install --save-dev webpack-bundle-analyzer

# Add to webpack config
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
plugins: [new BundleAnalyzerPlugin()],

# Results: Interactive treemap showing:
# - Which modules are largest
# - What's in each chunk
# - Opportunities for further splitting

πŸ› Real-World Scenario: Fixing 3-Second Initial Load at E-Commerce Checkout​

Situation: An e-commerce app had a 3-second Time to Interactive (TTI) on the checkout page. Users abandoned carts before the page became usable.

Investigation Using Performance Profiler:

// Initial bundle analysis revealed:
// main.bundle.js: 1.2MB (unmified)
// - React + ReactDOM: 150KB
// - Lodash (entire library): 540KB ❌
// - Moment.js (entire library): 290KB ❌
// - Checkout components: 120KB
// - Payment SDKs (Stripe, PayPal): 200KB ❌

Problems Identified:

  1. Heavy Libraries Imported Entirely: Lodash and Moment.js were imported in their entirety even though only 3-4 functions were used.
  2. Payment SDKs Loaded Upfront: Stripe and PayPal SDKs loaded immediately, even though users might not choose those payment methods.
  3. No Route-Based Splitting: All routes bundled together.

Solution Implementation:

// BEFORE: Everything in main bundle
import _ from 'lodash'; // ❌ 540KB
import moment from 'moment'; // ❌ 290KB
import StripeCheckout from './StripeCheckout';
import PayPalCheckout from './PayPalCheckout';

// AFTER: Aggressive code splitting

// 1. Tree-shakeable imports
import debounce from 'lodash/debounce'; // βœ… 5KB
import throttle from 'lodash/throttle'; // βœ… 3KB
import dayjs from 'dayjs'; // βœ… 7KB (Moment.js replacement)

// 2. Lazy load payment SDKs
const StripeCheckout = lazy(() => import(
/* webpackChunkName: "stripe" */
'./StripeCheckout'
));
const PayPalCheckout = lazy(() => import(
/* webpackChunkName: "paypal" */
'./PayPalCheckout'
));

function CheckoutPage() {
const [paymentMethod, setPaymentMethod] = useState(null);

return (
<div>
<PaymentMethodSelector onChange={setPaymentMethod} />

{/* Only load selected payment SDK */}
<Suspense fallback={<PaymentSkeleton />}>
{paymentMethod === 'stripe' && <StripeCheckout />}
{paymentMethod === 'paypal' && <PayPalCheckout />}
</Suspense>
</div>
);
}

// 3. Route-based splitting
const ProductPage = lazy(() => import('./pages/Product'));
const CartPage = lazy(() => import('./pages/Cart'));
const CheckoutPage = lazy(() => import('./pages/Checkout'));

Metrics After Optimization:

MetricBeforeAfterImprovement
Main bundle size1.2MB280KB-77%
Initial TTI3.1s0.9s-71%
Lighthouse Performance4294+124%
Cart abandonment rate28%14%-50%
Revenue impact-+$180K/month-

Key Lessons:

  1. Measure First: Use Webpack Bundle Analyzer before optimizing.
  2. Replace Heavy Libraries: Swap Moment.js β†’ Day.js (saves 283KB), Lodash β†’ specific imports (saves 500KB+).
  3. Lazy Load Conditional Features: Payment SDKs, modals, admin panels.
  4. Monitor Real User Metrics: Use tools like Sentry, LogRocket to track actual load times.

Advanced Pattern - Preloading on Hover:

// Preload chunks when user hovers over navigation links
function NavigationLink({ to, children }) {
const handleMouseEnter = () => {
// Trigger preload of route chunk
const Component = routeMap[to];
Component.preload?.(); // If using react-router + lazy
};

return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
}

This reduces perceived load time from 500ms to ~50ms because the chunk is already downloaded when the user clicks.


<details> <summary><strong>βš–οΈ Trade-offs: When to Split (and When NOT to)</strong></summary>

Code Splitting is NOT Always the Right Choice:

ScenarioShould Split?Reasoning
Small components (<20KB)❌ NoNetwork overhead (HTTP request) costs more than bundle size savings.
Components always visible❌ NoUsers will load it anyway; splitting adds complexity without benefit.
Components used on 80%+ pages❌ NoBetter to include in main bundle for caching efficiency.
Route-based pagesβœ… YesUsers might never visit certain routes; perfect split opportunity.
Modal/dialog componentsβœ… YesOnly loaded when opened, often never used in a session.
Admin panels (role-restricted)βœ… Yes95% of users never access; massive savings for regular users.
Third-party SDKs (analytics, chat)βœ… YesOften 200KB+; can load after critical rendering.

Trade-off Analysis: Bundle Size vs Network Requests

// Scenario: 10 small components (5KB each)

// Option 1: Split everything (❌ BAD)
const A = lazy(() => import('./A')); // +1 request
const B = lazy(() => import('./B')); // +1 request
// ... 10 total requests
// Cost: 10 network requests Γ— 50ms latency = 500ms overhead
// Savings: 50KB not loaded immediately
// Net result: SLOWER (latency dominates)

// Option 2: Bundle into logical groups (βœ… GOOD)
const CommonUtils = lazy(() => import('./utils/bundle'));
// 1 request for 50KB chunk
// Cost: 1 request Γ— 50ms = 50ms
// Savings: 50KB not loaded immediately
// Net result: FASTER

Rule of Thumb: Split Threshold = 30KB minimum

Below 30KB, the network overhead typically outweighs the bundle savings.

HTTP/2 Multiplexing Changes the Equation:

With HTTP/2, multiple files can download simultaneously over one connection. This makes fine-grained splitting more viable:

// Pre-HTTP/2: Limit chunks to 5-10 (connection limit)
// HTTP/2: Can have 20-30 chunks without penalty

// Modern approach: Split by logical feature
const UserProfile = lazy(() => import('./features/UserProfile'));
const Analytics = lazy(() => import('./features/Analytics'));
const Comments = lazy(() => import('./features/Comments'));
const Notifications = lazy(() => import('./features/Notifications'));

Caching Trade-offs:

// Monolithic bundle (❌ BAD for updates)
main.bundle.js (1MB)
// User updates one component β†’ entire 1MB re-downloaded

// Well-split bundles (βœ… GOOD for updates)
vendors.chunk.js (500KB, changes rarely)
common.chunk.js (200KB, changes monthly)
dashboard.chunk.js (100KB, changes weekly)
profile.chunk.js (50KB, changes weekly)
// User updates profile β†’ only 50KB re-downloaded

Mobile vs Desktop Considerations:

// Detect connection quality and adjust loading strategy
import { lazy, Suspense } from 'react';

function AdaptiveLoader() {
const connection = navigator.connection || {};
const isSlow = connection.effectiveType === '2g' ||
connection.effectiveType === 'slow-2g';

if (isSlow) {
// On slow connections: fewer, larger chunks
return <BasicUI />;
}

// On fast connections: more granular splitting
const AdvancedFeatures = lazy(() => import('./AdvancedFeatures'));
return (
<Suspense fallback={<Skeleton />}>
<AdvancedFeatures />
</Suspense>
);
}

Developer Experience Trade-offs:

AspectAggressive SplittingConservative Splitting
Bundle sizeOptimal (small chunks)Suboptimal (larger bundles)
DebuggingHarder (many files)Easier (fewer files)
Build timeSlower (more chunks)Faster (fewer chunks)
Cache invalidationBetter (granular)Worse (monolithic)
Stack tracesMore complexSimpler

Decision Framework:

START: Should I split this component?
β”‚
β”œβ”€ Is it >30KB? ──NO──> Don't split
β”‚ β”‚
β”‚ YES
β”‚ β”‚
β”œβ”€ Is it always visible? ──YES──> Don't split
β”‚ β”‚
β”‚ NO
β”‚ β”‚
β”œβ”€ Is it used on >70% of pages? ──YES──> Don't split
β”‚ β”‚
β”‚ NO
β”‚ β”‚
└─> βœ… SPLIT IT!

πŸ’¬ Explain to Junior: Code Splitting Like a Restaurant Menu​

Analogy: Restaurant Menu vs Buffet

Imagine a restaurant that serves 100 dishes. Two approaches:

Buffet (No Code Splitting):

  • The kitchen prepares ALL 100 dishes upfront.
  • Every customer waits 30 minutes for all dishes to be ready.
  • Most customers only eat 5-10 dishes.
  • 90% of food preparation was wasted effort.

Menu (Code Splitting):

  • The kitchen only prepares dishes when ordered.
  • Customers wait 3 minutes for their specific dishes.
  • No wasted preparation on unordered dishes.
  • If someone orders something new, the kitchen makes it then.

In Web Development:

// BUFFET APPROACH (No splitting)
import Dashboard from './Dashboard'; // 200KB
import AdminPanel from './AdminPanel'; // 300KB
import Reports from './Reports'; // 400KB
import Settings from './Settings'; // 100KB

function App() {
return <Dashboard />; // Only using Dashboard
// But loaded 1MB total! (200+300+400+100)
}

// MENU APPROACH (Code splitting)
const Dashboard = lazy(() => import('./Dashboard')); // 200KB
const AdminPanel = lazy(() => import('./AdminPanel')); // Only if admin
const Reports = lazy(() => import('./Reports')); // Only if clicked
const Settings = lazy(() => import('./Settings')); // Only if accessed

function App() {
return (
<Suspense fallback={<Loading />}>
<Dashboard /> {/* Only loads 200KB initially */}
</Suspense>
);
}

What is Suspense?

Think of Suspense as a "loading placeholder" manager:

<Suspense fallback={<Spinner />}>
<LazyComponent />
</Suspense>

// What happens:
// 1. React sees LazyComponent isn't loaded yet
// 2. React shows <Spinner /> while downloading
// 3. Once downloaded, React swaps Spinner with LazyComponent

Interview Answer Template:

"Code splitting is breaking your application into smaller bundles that load on demand, rather than loading everything upfront. You use React.lazy() with dynamic imports to define which components should be split, and wrap them in <Suspense> to show a fallback while loading.

For example, in an e-commerce app, I would split the checkout flow because most users browse products but don't complete purchases. This reduces the initial bundle from 1MB to 300KB, improving Time to Interactive from 3 seconds to under 1 second.

The key trade-off is balancing bundle size reduction against the overhead of extra network requests. I typically split routes, large third-party libraries, and conditional features like modals or admin panels."

Common Gotchas:

// ❌ WRONG: Lazy inside component
function App() {
const LazyComponent = lazy(() => import('./Component'));
// This creates a NEW lazy component every render!
return <LazyComponent />;
}

// βœ… CORRECT: Lazy at module level
const LazyComponent = lazy(() => import('./Component'));
function App() {
return <LazyComponent />;
}

When a Junior Should Use Code Splitting:

  1. Different pages/routes - Always split routes.
  2. Large libraries - Split Chart.js, PDF viewers, rich text editors.
  3. Admin-only features - 95% of users never see them.
  4. Modals and dialogs - Only load when opened.

When NOT to split:

  1. Small components (<20KB) - Not worth the overhead.
  2. Shared utilities - If used everywhere, keep in main bundle.
  3. Critical above-the-fold content - Users need it immediately.

Question 3: React Profiler and Performance Debugging​

Difficulty: 🟑 Medium Frequency: ⭐⭐⭐⭐ Time: 7 minutes Companies: Meta, Netflix, large-scale apps

Question​

How do you use React Profiler to identify and fix performance issues?

Answer​

React Profiler - Built-in tool to measure rendering performance.

Code Example​

// 1. PROFILER COMPONENT
import { Profiler } from 'react';

function onRenderCallback(
id, // component id
phase, // "mount" or "update"
actualDuration, // time spent rendering
baseDuration, // estimated time without memoization
startTime,
commitTime,
interactions
) {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
}

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

// 2. REACT DEVTOOLS PROFILER
// - Open React DevTools
// - Click "Profiler" tab
// - Click record button
// - Interact with your app
// - Stop recording
// - Analyze flame graph & ranked chart

// 3. PERFORMANCE PATTERNS

// ❌ BAD: Creating objects in render
function Bad({ userId }) {
return <User config={{ id: userId, theme: 'dark' }} />;
// New object every render!
}

// βœ… GOOD: Memoize objects
function Good({ userId }) {
const config = useMemo(
() => ({ id: userId, theme: 'dark' }),
[userId]
);
return <User config={config} />;
}

// ❌ BAD: Inline functions
function Bad() {
return <Button onClick={() => console.log('click')} />;
// New function every render
}

// βœ… GOOD: useCallback
function Good() {
const handleClick = useCallback(() => {
console.log('click');
}, []);
return <Button onClick={handleClick} />;
}

Resources​


πŸ” Deep Dive: React Profiler Internals and Performance Measurement​

How the Profiler API Works Under the Hood:

The React Profiler is a built-in component that measures rendering performance by wrapping parts of your component tree and collecting timing data during the commit phase.

Profiler Callback Parameters Explained:

function onRenderCallback(
id, // Unique string identifying the Profiler
phase, // "mount" (first render) or "update" (re-render)
actualDuration, // Time spent rendering this update (ms)
baseDuration, // Estimated time to render entire tree without memoization (ms)
startTime, // When React began rendering this update
commitTime, // When React committed this update
interactions // Set of interactions (deprecated in React 18)
) {
// Log or send to analytics
console.log(`${id} ${phase} took ${actualDuration}ms`);

// Potential performance issue if actualDuration >> expected
if (actualDuration > 16) { // 60fps = 16.67ms per frame
console.warn(`${id} exceeded frame budget!`);
}
}

Key Metrics Breakdown:

  1. actualDuration: The REAL time spent rendering the profiled tree. This includes time for:

    • Running component functions
    • Executing hooks (useState, useEffect, useMemo, etc.)
    • Diffing the virtual DOM
    • Committing changes to the actual DOM
  2. baseDuration: The THEORETICAL time to render the entire tree if NOTHING was memoized. This helps you understand how much optimization you've achieved.

  3. actualDuration vs baseDuration: If actualDuration is much smaller than baseDuration, your memoization is working well. If they're similar, you might have missed optimization opportunities.

React DevTools Profiler Deep Dive:

The browser extension provides visual profiling tools:

Profiler Tab Components:
β”œβ”€ Flamegraph: Shows component hierarchy with render times
β”‚ - Longer bars = slower components
β”‚ - Color intensity = relative performance
β”‚ - Click component to see details
β”‚
β”œβ”€ Ranked: Lists components sorted by render time
β”‚ - Quickly identify slowest components
β”‚ - Shows render count and total time
β”‚
└─ Timeline: Shows render sequence over time
- Multiple renders in quick succession = potential issue
- Helps identify unnecessary re-renders

Advanced Profiling with Performance API:

import { Profiler, startTransition } from 'react';

function DetailedProfiler({ id, children }) {
const onRender = (id, phase, actualDuration, baseDuration) => {
// Send to analytics service
window.performance.mark(`${id}-${phase}-start`);
window.performance.measure(
`${id}-${phase}`,
`${id}-${phase}-start`
);

// Track in real-user monitoring (RUM)
if (window.gtag) {
window.gtag('event', 'render_performance', {
component: id,
phase,
duration: actualDuration,
saved: baseDuration - actualDuration,
});
}
};

return (
<Profiler id={id} onRender={onRender}>
{children}
</Profiler>
);
}

React 18 Concurrent Rendering Impact on Profiling:

With Concurrent Features (useTransition, useDeferredValue), React can interrupt rendering:

function SearchResults() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();

const handleChange = (e) => {
setQuery(e.target.value);

// Non-urgent update - can be interrupted
startTransition(() => {
setDeferredQuery(e.target.value);
});
};

return (
<Profiler id="SearchResults" onRender={onRender}>
{/* Urgent: input updates immediately */}
<input value={query} onChange={handleChange} />

{/* Non-urgent: results can wait */}
<Suspense fallback={<Skeleton />}>
<ExpensiveResults query={deferredQuery} />
</Suspense>
</Profiler>
);
}

In this case, the Profiler might show multiple phases for a single user interaction because React splits the work.

Identifying Performance Bottlenecks:

// Common performance issues the Profiler reveals:

// 1. LARGE LISTS WITHOUT VIRTUALIZATION
function BadList({ items }) {
return items.map(item => <ListItem key={item.id} {...item} />);
// Profiler shows: 500ms+ for 10,000 items
}

// Fix: React Window or React Virtualized
import { FixedSizeList } from 'react-window';

function GoodList({ items }) {
return (
<FixedSizeList
height={500}
itemCount={items.length}
itemSize={50}
>
{({ index, style }) => (
<div style={style}>
<ListItem {...items[index]} />
</div>
)}
</FixedSizeList>
);
// Profiler shows: 20ms for 10,000 items (only renders visible)
}

// 2. EXPENSIVE CALCULATIONS IN RENDER
function BadCalculation({ data }) {
const result = expensiveOperation(data); // Runs every render!
return <div>{result}</div>;
// Profiler shows: 200ms per render
}

function GoodCalculation({ data }) {
const result = useMemo(() => expensiveOperation(data), [data]);
return <div>{result}</div>;
// Profiler shows: 200ms first render, 1ms subsequent renders
}

// 3. CONTEXT UPDATES CAUSING MASS RE-RENDERS
const ThemeContext = createContext();

function BadProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = { theme, setTheme }; // New object every render!
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
// Profiler shows: ALL consumers re-render on ANY state change
}

function GoodProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
// Profiler shows: Only consumers using changed values re-render
}

Custom Performance Monitoring Hooks:

function useRenderCount(componentName) {
const renders = useRef(0);

useEffect(() => {
renders.current++;
console.log(`${componentName} rendered ${renders.current} times`);
});
}

function useWhyDidYouUpdate(componentName, props) {
const previousProps = useRef();

useEffect(() => {
if (previousProps.current) {
const allKeys = Object.keys({ ...previousProps.current, ...props });
const changedProps = {};

allKeys.forEach(key => {
if (previousProps.current[key] !== props[key]) {
changedProps[key] = {
from: previousProps.current[key],
to: props[key],
};
}
});

if (Object.keys(changedProps).length > 0) {
console.log('[why-did-you-update]', componentName, changedProps);
}
}

previousProps.current = props;
});
}

// Usage:
function MyComponent(props) {
useRenderCount('MyComponent');
useWhyDidYouUpdate('MyComponent', props);

return <div>...</div>;
}

Profiler API Limitations:

  • Production Builds: Profiler is disabled in production by default (performance overhead).
  • Sampling: In large apps, profiling every render can slow down the app itself.
  • Async Operations: Doesn't measure network requests, setTimeout, or async logic outside React.

Solution: Selective Profiling

// Only profile in development or with feature flag
const shouldProfile = process.env.NODE_ENV === 'development' ||
window.ENABLE_PROFILING;

function ConditionalProfiler({ id, children }) {
if (shouldProfile) {
return (
<Profiler id={id} onRender={onRenderCallback}>
{children}
</Profiler>
);
}
return <>{children}</>;
}

πŸ› Real-World Scenario: Debugging 2-Second Lag in Admin Dashboard​

Situation: A SaaS admin dashboard became unusable after adding a new feature. Users reported a 2-second delay when typing in search fields, and the entire interface froze during interactions.

Investigation Process:

Step 1: Reproduce the Issue

// The problematic component:
function AdminDashboard() {
const [searchQuery, setSearchQuery] = useState('');
const [users, setUsers] = useState([]);

const handleSearch = (e) => {
setSearchQuery(e.target.value);
// This triggers re-render of entire dashboard
};

return (
<div>
<input value={searchQuery} onChange={handleSearch} />
<UserTable users={users} searchQuery={searchQuery} />
<AnalyticsPanel />
<ActivityFeed />
<NotificationCenter />
</div>
);
}

Step 2: Enable React Profiler

function AdminDashboard() {
return (
<Profiler id="AdminDashboard" onRender={onRenderCallback}>
{/* ... components */}
</Profiler>
);
}

function onRenderCallback(id, phase, actualDuration) {
console.log(`${id} ${phase}: ${actualDuration}ms`);

if (actualDuration > 16) {
console.warn('⚠️ Frame budget exceeded!', {
component: id,
phase,
duration: actualDuration,
target: '16ms for 60fps',
});
}
}

Results from Profiler:

AdminDashboard update: 1,847ms ❌
β”œβ”€ UserTable update: 1,203ms ❌
β”‚ └─ Rendering 5,000 rows without virtualization
β”œβ”€ AnalyticsPanel update: 512ms ❌
β”‚ └─ Recalculating charts on every keystroke
β”œβ”€ ActivityFeed update: 89ms ⚠️
β”‚ └─ Re-rendering all 200 feed items
└─ NotificationCenter update: 43ms βœ…

Step 3: Identify Root Causes

Using React DevTools Profiler Flamegraph:

  1. UserTable: Rendering 5,000 table rows on every keystroke (1,203ms)
  2. AnalyticsPanel: Recalculating 10 charts unnecessarily (512ms)
  3. ActivityFeed: Re-rendering all feed items when search changes (89ms)

Step 4: Apply Targeted Fixes

Fix 1: Memoize UserTable

// BEFORE: Re-renders on every search change
function UserTable({ users, searchQuery }) {
const filteredUsers = users.filter(u =>
u.name.includes(searchQuery)
); // Expensive filter runs every render

return (
<table>
{filteredUsers.map(user => <UserRow key={user.id} user={user} />)}
</table>
);
}

// AFTER: Memoized filtering + virtualization
const UserTable = memo(function UserTable({ users, searchQuery }) {
const filteredUsers = useMemo(() => {
return users.filter(u =>
u.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [users, searchQuery]);

return (
<FixedSizeList
height={600}
itemCount={filteredUsers.length}
itemSize={50}
>
{({ index, style }) => (
<UserRow
key={filteredUsers[index].id}
user={filteredUsers[index]}
style={style}
/>
)}
</FixedSizeList>
);
});

// Profiler result: 1,203ms β†’ 23ms (98% improvement)

Fix 2: Debounce Search + Separate State

// BEFORE: Every keystroke triggers expensive operations
const [searchQuery, setSearchQuery] = useState('');

// AFTER: Debounced search state
const [inputValue, setInputValue] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');

const debouncedSearch = useMemo(
() => debounce((value) => setDebouncedQuery(value), 300),
[]
);

const handleInputChange = (e) => {
const value = e.target.value;
setInputValue(value); // Immediate UI update
debouncedSearch(value); // Delayed expensive operation
};

// Input feels instant, but heavy operations only run after 300ms pause
// Profiler result: 1,847ms every keystroke β†’ 1,847ms every 300ms+ pause

Fix 3: Isolate Expensive Components

// BEFORE: AnalyticsPanel re-calculates on every render
function AnalyticsPanel() {
const chartData = calculateChartData(); // Expensive!
return <Charts data={chartData} />;
}

// AFTER: Memoized and context-isolated
const AnalyticsPanel = memo(function AnalyticsPanel() {
const chartData = useMemo(() => calculateChartData(), []);
return <Charts data={chartData} />;
});

// Profiler result: 512ms per search β†’ 512ms once (on mount only)

Step 5: Verify Improvements

Before Optimization:

Typing "john" (4 keystrokes):
β”œβ”€ Keystroke 1: 1,847ms ❌
β”œβ”€ Keystroke 2: 1,851ms ❌
β”œβ”€ Keystroke 3: 1,849ms ❌
└─ Keystroke 4: 1,853ms ❌
Total: 7.4 seconds of lag
User experience: Completely frozen

After Optimization:

Typing "john" (4 keystrokes):
β”œβ”€ Keystroke 1: 3ms βœ… (input only)
β”œβ”€ Keystroke 2: 2ms βœ… (input only)
β”œβ”€ Keystroke 3: 3ms βœ… (input only)
β”œβ”€ Keystroke 4: 2ms βœ… (input only)
β”œβ”€ [300ms pause]
└─ Search execution: 28ms βœ… (virtualized table)
Total: 38ms total lag
User experience: Buttery smooth

Metrics Comparison:

MetricBeforeAfterImprovement
Input lag per keystroke1,850ms2-3ms-99.8%
Total search time7.4s (4 keystrokes)38ms-99.5%
Re-renders per keystroke4 components1 component (input)-75%
User satisfaction score2.1/54.8/5+129%

Key Takeaways:

  1. Use Profiler Early: Don't wait for users to complain. Profile during development.
  2. Debounce Expensive Operations: Separate UI state (input) from data state (search results).
  3. Virtualize Long Lists: Only render visible items (react-window, react-virtualized).
  4. Memoize Calculations: Use useMemo for expensive data transformations.
  5. Isolate Components: Use memo() to prevent unnecessary re-renders.

</details>


<details> <summary><strong>βš–οΈ Trade-offs: Profiling Overhead and When to Optimize</strong></summary>

The Profiler API Has Performance Costs:

// Profiler adds ~1-3% overhead in development
<Profiler id="App" onRender={callback}>
<App />
</Profiler>

// Overhead breakdown:
// - Time tracking: ~0.5-1ms per component
// - Callback execution: ~0.5-1ms
// - Memory for storing measurements: ~10KB per render

When Profiling Becomes the Bottleneck:

In large applications with hundreds of components, wrapping everything in Profiler can slow down the app more than the issues you're trying to find.

Strategic Profiling Approach:

// ❌ BAD: Profile everything
<Profiler id="App">
<Profiler id="Header">
<Profiler id="Navigation">
<Profiler id="MenuItem">
{/* Too granular, too much overhead */}
</Profiler>
</Profiler>
</Profiler>
</Profiler>

// βœ… GOOD: Profile suspected slow areas only
<App>
<Header /> {/* Fast, skip profiling */}

<Profiler id="Dashboard">
<Dashboard /> {/* Suspected slow, profile this */}
</Profiler>

<Footer /> {/* Fast, skip profiling */}
</App>

Trade-off: Optimization Time vs Performance Gain

OptimizationTime InvestmentPerformance GainWorth It?
useMemo for cheap calculations5 min0.1ms saved❌ No
useMemo for expensive calculations5 min500ms savedβœ… Yes
Memo on leaf components10 min2ms saved❌ Rarely
Memo on expensive trees10 min300ms savedβœ… Yes
Virtualize 50-item list30 min5ms saved❌ No
Virtualize 5,000-item list30 min1,200ms savedβœ… Absolutely

Rule of Thumb: Only Optimize if Profiler Shows >16ms

16ms = 60 FPS frame budget. If a component renders in <16ms, users won't notice.

Premature Optimization Dangers:

// ❌ OVER-OPTIMIZED: Too much memoization
function OverOptimized({ name, age }) {
const greeting = useMemo(() => `Hello, ${name}`, [name]); // Unnecessary
const ageLabel = useMemo(() => `Age: ${age}`, [age]); // Unnecessary
const displayName = useMemo(() => name.toUpperCase(), [name]); // Unnecessary

return <div>{greeting} {ageLabel} {displayName}</div>;
// Memoization overhead > actual calculation cost
// Code is harder to read and maintain
}

// βœ… RIGHT AMOUNT: Only memoize expensive work
function RightAmount({ items, filter }) {
const filteredItems = useMemo(() => {
return items.filter(item => expensiveFilter(item, filter));
}, [items, filter]); // Worth it: filtering 10,000 items

return <List items={filteredItems} />;
}

React DevTools Profiler vs Production Monitoring:

AspectDevTools ProfilerProduction RUM
EnvironmentDevelopment onlyReal users
AccuracyExact measurementsApproximate (sampling)
CoverageSpecific scenariosAll user interactions
CostFree (dev overhead)Paid services (Sentry, LogRocket)
Use caseDebugging specific issuesMonitoring overall performance

Recommendation: Use Both

// Development: React DevTools Profiler
// - Deep dive into specific components
// - Understand why re-renders happen
// - Compare before/after optimization

// Production: Real User Monitoring (RUM)
import * as Sentry from '@sentry/react';

Sentry.init({
dsn: 'your-dsn',
integrations: [
new Sentry.BrowserTracing({
tracingOrigins: ['localhost', /^\//],
}),
],
tracesSampleRate: 0.1, // Sample 10% of users (cost control)
});

// Track custom performance metrics
Sentry.startTransaction({
name: 'Dashboard Load',
op: 'navigation',
});

When NOT to Profile:

  1. Components rendering <5ms: Not worth the time investment.
  2. One-time mounts: Optimizing initial mount is usually unnecessary.
  3. Rarely used features: Focus on 80% use cases first.

When to Profile Aggressively:

  1. High-traffic pages: Homepage, dashboards, search results.
  2. Interactive features: Forms, filters, real-time updates.
  3. Mobile experiences: Limited CPU/network, every ms counts.
  4. Critical user flows: Checkout, onboarding, signup.

πŸ’¬ Explain to Junior: React Profiler is Like a Stopwatch for Your Components​

Analogy: Restaurant Kitchen Performance Audit

Imagine you own a restaurant and customers are complaining about slow service. You need to find out WHY.

Without Profiler (Guessing):

  • "Maybe the chef is slow?"
  • "Maybe we need more waiters?"
  • "Maybe the menu is too complex?"
  • You're just guessing and might waste money fixing the wrong thing.

With Profiler (Measuring):

  • Install cameras and stopwatches in the kitchen.
  • Measure: "Appetizer takes 2 minutes. Main course takes 45 minutes!"
  • AHA! The main course is the bottleneck, not the chef or waiters.
  • Fix: Prep ingredients in advance, parallelize cooking.

React Profiler Does the Same for Your Code:

// Without Profiler: "My app is slow, but I don't know why"

// With Profiler:
<Profiler id="Dashboard" onRender={(id, phase, actualDuration) => {
console.log(`${id} took ${actualDuration}ms`);
}}>
<Dashboard />
</Profiler>

// Output:
// Dashboard took 1,234ms
// Now you KNOW it's the Dashboard causing slowness!

How to Use React DevTools Profiler (Visual Tool):

  1. Install React DevTools (Chrome/Firefox extension)
  2. Open your app in the browser
  3. Click the Profiler tab in React DevTools
  4. Click the record button (πŸ”΄)
  5. Interact with your app (type, click, scroll)
  6. Stop recording (⏹️)
  7. Analyze the results:

What You'll See:

  • Flamegraph: Shows components as bars. Longer bars = slower components.
  • Ranked View: Lists components sorted by render time (slowest first).
  • Timeline: Shows when components rendered over time.

Example Flamegraph Interpretation:

App (1,234ms total)
β”œβ”€ Header (5ms) βœ… Fast
β”œβ”€ Sidebar (8ms) βœ… Fast
└─ Dashboard (1,221ms) ❌ SLOW!
β”œβ”€ Chart (12ms) βœ… Fast
└─ Table (1,209ms) ❌ THE PROBLEM!

You found it! The Table component is the bottleneck.

Fixing the Problem:

// BEFORE: Slow table (1,209ms)
function Table({ data }) {
return (
<table>
{data.map(row => <TableRow key={row.id} row={row} />)}
</table>
);
// Rendering 10,000 rows = slow!
}

// AFTER: Virtualized table (23ms)
import { FixedSizeList } from 'react-window';

function Table({ data }) {
return (
<FixedSizeList
height={600}
itemCount={data.length}
itemSize={50}
>
{({ index, style }) => (
<TableRow key={data[index].id} row={data[index]} style={style} />
)}
</FixedSizeList>
);
// Only renders 20 visible rows = fast!
}

Interview Answer Template:

"The React Profiler is a tool that measures how long components take to render. There are two ways to use it:

  1. Profiler API: You wrap components in a <Profiler> component and provide a callback function that logs render times. This is useful for tracking performance in production or specific scenarios.

  2. React DevTools Profiler: A browser extension that visually shows you which components are slow. You record a session, interact with your app, and then analyze a flamegraph that highlights slow components.

I use the Profiler to identify performance bottlenecks. For example, I once discovered a table component was taking 1.2 seconds to render because it was rendering 10,000 rows. By virtualizing it with react-window, I reduced render time to 23ms.

The key is to focus on components that take more than 16ms to render, since that's the budget for 60 FPS. Optimizing faster components is usually premature optimization."

Common Mistakes Juniors Make:

// ❌ MISTAKE 1: Profiling in production builds
// Production builds disable Profiler by default for performance

// ❌ MISTAKE 2: Over-optimizing fast components
const name = useMemo(() => user.name, [user]);
// This is slower than just using user.name directly!

// ❌ MISTAKE 3: Not comparing before/after
// Always profile BEFORE optimization to set a baseline
// Then profile AFTER to confirm improvement

When to Profile (Simple Rules):

  1. Your app feels slow - Profile to find the culprit.
  2. Before optimizing - Measure first, optimize second.
  3. After adding a feature - Ensure new code doesn't slow things down.
  4. Before deploying - Catch performance regressions.

What the Profiler CAN'T Do:

  • Measure network requests (use Network tab for that).
  • Measure CSS layout/paint (use Chrome Performance tab for that).
  • Automatically fix issues (you still need to write better code).

</details>


← Back to React README


Source: performance-02-memoization.md