
React Performance Optimizer
- 145 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
Profile React apps to cut bundle size, reduce re-renders, fix memoization gaps, and improve Core Web Vitals before high-traffic launches.
About
Diagnoses and fixes React performance bottlenecks—virtualizing lists, stabilizing hooks, trimming bundles, and optimizing renders—to improve LCP, INP, and TTI for production SaaS, extension, and mobile-web frontends.
- Re-render profiling
- Code splitting and lazy routes
- Memoization and hook stabilization
- Core Web Vitals tuning
React Performance Optimizer by the numbers
- 145 all-time installs (skills.sh)
- Ranked #962 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill react-performance-optimizerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 145 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
What it does
Profile React apps to cut bundle size, reduce re-renders, fix memoization gaps, and improve Core Web Vitals before high-traffic launches.
Files
React Performance Optimizer
Expert in diagnosing and fixing React performance issues to achieve buttery-smooth 60fps experiences.
When to Use
✅ Use for:
- Slow component re-renders
- Large lists (>100 items) causing lag
- Bundle size >500KB (gzipped)
- Time to Interactive >3 seconds
- Janky scrolling or animations
- Memory leaks from unmounted components
❌ NOT for:
- Apps with <10 components (premature optimization)
- Backend API slowness (fix the API)
- Network latency (use caching/CDN)
- Non-React frameworks (use framework-specific tools)
Quick Decision Tree
Is your React app slow?
├── Profiler shows >16ms renders? → Use memoization
├── Lists with >100 items? → Use virtualization
├── Bundle size >500KB? → Code splitting
├── Lighthouse score <70? → Multiple optimizations
└── Feels fast enough? → Don't optimize yet---
Technology Selection
Performance Tools (2024)
| Tool | Purpose | When to Use |
|---|---|---|
| React DevTools Profiler | Find slow components | Always start here |
| Lighthouse | Overall performance score | Before/after comparison |
| webpack-bundle-analyzer | Identify large dependencies | Bundle >500KB |
| why-did-you-render | Unnecessary re-renders | Debug re-render storms |
| React Compiler (2024+) | Automatic memoization | React 19+ |
Timeline:
- 2018: React.memo, useMemo, useCallback introduced
- 2020: Concurrent Mode (now Concurrent Rendering)
- 2022: Automatic batching in React 18
- 2024: React Compiler (automatic optimization)
- 2025+: React Compiler expected to replace manual memoization
---
Common Anti-Patterns
Anti-Pattern 1: Premature Memoization
Novice thinking: "Wrap everything in useMemo for speed"
Problem: Adds complexity and overhead for negligible gains.
Wrong approach:
// ❌ Over-optimization
function UserCard({ user }) {
const fullName = useMemo(() => `${user.first} ${user.last}`, [user]);
const age = useMemo(() => new Date().getFullYear() - user.birthYear, [user]);
return <div>{fullName}, {age}</div>;
}Why wrong: String concatenation is faster than useMemo overhead.
Correct approach:
// ✅ Simple is fast
function UserCard({ user }) {
const fullName = `${user.first} ${user.last}`;
const age = new Date().getFullYear() - user.birthYear;
return <div>{fullName}, {age}</div>;
}Rule of thumb: Only memoize if: 1. Computation takes >5ms (use Profiler to measure) 2. Result used in dependency array 3. Prevents child re-renders
---
Anti-Pattern 2: Not Memoizing Callbacks
Problem: New function instance on every render breaks React.memo.
Wrong approach:
// ❌ Child re-renders on every parent render
function Parent() {
const [count, setCount] = useState(0);
return (
<Child onUpdate={() => setCount(count + 1)} />
);
}
const Child = React.memo(({ onUpdate }) => {
return <button onClick={onUpdate}>Update</button>;
});Why wrong: Arrow function creates new reference → React.memo useless.
Correct approach:
// ✅ Stable callback reference
function Parent() {
const [count, setCount] = useState(0);
const handleUpdate = useCallback(() => {
setCount(c => c + 1); // Updater function avoids dependency
}, []);
return <Child onUpdate={handleUpdate} />;
}
const Child = React.memo(({ onUpdate }) => {
return <button onClick={onUpdate}>Update</button>;
});---
Anti-Pattern 3: Rendering Large Lists Without Virtualization
Problem: Rendering 1000+ DOM nodes causes lag.
Symptom: Scrolling feels janky, initial render slow.
Wrong approach:
// ❌ Renders all 10,000 items
function UserList({ users }) {
return (
<div>
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}Correct approach:
// ✅ Only renders visible items
import { FixedSizeList } from 'react-window';
function UserList({ users }) {
return (
<FixedSizeList
height={600}
itemCount={users.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<UserCard user={users[index]} />
</div>
)}
</FixedSizeList>
);
}Impact: 10,000 items: 5 seconds → 50ms render time.
---
Anti-Pattern 4: No Code Splitting
Problem: 2MB bundle downloaded upfront, slow initial load.
Wrong approach:
// ❌ Everything in main bundle
import AdminPanel from './AdminPanel'; // 500KB
import Dashboard from './Dashboard';
import Settings from './Settings';
function App() {
return (
<Routes>
<Route path="/admin" element={<AdminPanel />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
);
}Correct approach:
// ✅ Lazy load routes
import { lazy, Suspense } from 'react';
const AdminPanel = lazy(() => import('./AdminPanel'));
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/admin" element={<AdminPanel />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}Impact: Initial bundle: 2MB → 300KB.
---
Anti-Pattern 5: Expensive Operations in Render
Problem: Heavy computation on every render.
Wrong approach:
// ❌ Sorts on every render (even when data unchanged)
function ProductList({ products }) {
const sorted = products.sort((a, b) => b.price - a.price);
return <div>{sorted.map(p => <Product product={p} />)}</div>;
}Correct approach:
// ✅ Memoize expensive operation
function ProductList({ products }) {
const sorted = useMemo(
() => [...products].sort((a, b) => b.price - a.price),
[products]
);
return <div>{sorted.map(p => <Product product={p} />)}</div>;
}---
Implementation Patterns
Pattern 1: React.memo for Pure Components
// Prevent re-render when props unchanged
const ExpensiveComponent = React.memo(({ data }) => {
// Complex rendering logic
return <div>{/* ... */}</div>;
});
// With custom comparison
const UserCard = React.memo(
({ user }) => <div>{user.name}</div>,
(prevProps, nextProps) => {
// Return true if props equal (skip re-render)
return prevProps.user.id === nextProps.user.id;
}
);Pattern 2: useMemo for Expensive Calculations
function DataTable({ rows, columns }) {
const sortedAndFiltered = useMemo(() => {
console.log('Recomputing...'); // Only logs when rows/columns change
return rows
.filter(row => row.visible)
.sort((a, b) => a.timestamp - b.timestamp);
}, [rows, columns]);
return <Table data={sortedAndFiltered} />;
}Pattern 3: useCallback for Stable References
function SearchBox({ onSearch }) {
const [query, setQuery] = useState('');
// Stable reference, doesn't break child memoization
const handleSubmit = useCallback(() => {
onSearch(query);
}, [query, onSearch]);
return (
<form onSubmit={handleSubmit}>
<input value={query} onChange={e => setQuery(e.target.value)} />
</form>
);
}Pattern 4: Virtualization (react-window)
import { VariableSizeList } from 'react-window';
function MessageList({ messages }) {
const getItemSize = (index) => {
// Dynamic heights based on content
return messages[index].text.length > 100 ? 80 : 50;
};
return (
<VariableSizeList
height={600}
itemCount={messages.length}
itemSize={getItemSize}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<Message message={messages[index]} />
</div>
)}
</VariableSizeList>
);
}Pattern 5: Code Splitting with React.lazy
// Route-based splitting
const routes = [
{ path: '/home', component: lazy(() => import('./Home')) },
{ path: '/about', component: lazy(() => import('./About')) },
{ path: '/contact', component: lazy(() => import('./Contact')) }
];
// Component-based splitting
const HeavyChart = lazy(() => import('./HeavyChart'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && (
<Suspense fallback={<Spinner />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}---
Production Checklist
□ Profiler analysis completed (identified slow components)
□ Large lists use virtualization (>100 items)
□ Routes code-split with React.lazy
□ Heavy components lazy-loaded
□ Callbacks memoized with useCallback
□ Expensive computations use useMemo
□ Pure components wrapped in React.memo
□ Bundle analyzed (no duplicate dependencies)
□ Tree-shaking enabled (ESM imports)
□ Images optimized and lazy-loaded
□ Lighthouse score >90
□ Time to Interactive <3 seconds---
When to Use vs Avoid
| Scenario | Optimize? |
|---|---|
| Rendering 1000+ list items | ✅ Yes - virtualize |
| Sorting/filtering large arrays | ✅ Yes - useMemo |
| Passing callbacks to memoized children | ✅ Yes - useCallback |
| String concatenation | ❌ No - fast enough |
| Simple arithmetic | ❌ No - don't memoize |
| 10-item list | ❌ No - premature optimization |
---
References
/references/profiling-guide.md- How to use React DevTools Profiler/references/bundle-optimization.md- Reduce bundle size strategies/references/memory-leaks.md- Detect and fix memory leaks
Scripts
scripts/performance_audit.ts- Automated performance checksscripts/bundle_analyzer.sh- Analyze and visualize bundle
---
This skill guides: React performance optimization | Memoization | Virtualization | Code splitting | Bundle optimization | Profiling
Bundle Optimization Strategies
Comprehensive guide to reducing JavaScript bundle size for faster load times.
Why Bundle Size Matters
Impact on User Experience:
- 100KB bundle (gzipped) → ~1s load on 3G
- 500KB bundle (gzipped) → ~5s load on 3G
- Every 100KB adds ~1s to Time to Interactive
Business Impact:
- Pinterest: -40% load time → +15% conversions
- AutoAnything: -50% load time → +12-13% sales
- BBC: Every 1s slower → -10% users
---
Current State Analysis
Step 1: Measure Your Bundle
# Next.js
npm run build
# Webpack
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json
# Vite
npm run build
# Check dist/ folder sizeStep 2: Set Targets
| Bundle Size (gzipped) | Rating | Action |
|---|---|---|
| <100KB | ✅ Excellent | Maintain |
| 100-300KB | ⚠️ Good | Monitor |
| 300-500KB | 🔴 Large | Optimize |
| >500KB | 🚨 Critical | Immediate action |
---
Strategy 1: Code Splitting
Route-Based Splitting (Easiest Win)
Before (single bundle):
import Home from './pages/Home';
import About from './pages/About';
import Dashboard from './pages/Dashboard'; // 500KBAfter (split by route):
import { lazy, Suspense } from 'react';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
);
}Impact: Main bundle: 800KB → 300KB (500KB lazy-loaded)
---
Component-Based Splitting
Split heavy components that aren't always shown:
import { lazy, Suspense } from 'react';
// ❌ Always loaded (even if modal never opens)
import PDFViewer from './PDFViewer'; // 200KB
// ✅ Loaded on demand
const PDFViewer = lazy(() => import('./PDFViewer'));
function App() {
const [showPDF, setShowPDF] = useState(false);
return (
<div>
<button onClick={() => setShowPDF(true)}>View PDF</button>
{showPDF && (
<Suspense fallback={<Spinner />}>
<PDFViewer url="/document.pdf" />
</Suspense>
)}
</div>
);
}When to split:
- Modals/dialogs
- Admin panels
- Charts/visualizations
- Rich text editors
- Video players
---
Vendor Splitting
Separate third-party code from your app code:
webpack.config.js:
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
// Vendor chunk (rarely changes)
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
// Common code shared across pages
common: {
minChunks: 2,
priority: -10,
reuseExistingChunk: true,
},
},
},
},
};Benefit: Vendor bundle cached long-term (changes infrequently)
---
Strategy 2: Tree Shaking
Use ES Modules (ESM)
❌ CommonJS (entire library imported):
const _ = require('lodash'); // 500KB
_.debounce(() => {}, 300);✅ ES Modules (only imported function included):
import { debounce } from 'lodash-es'; // ~5KB
debounce(() => {}, 300);Impact: 500KB → 5KB (99% reduction)
---
Import Only What You Need
❌ Whole library:
import * as MUI from '@mui/material'; // 300KB
function App() {
return <MUI.Button>Click</MUI.Button>;
}✅ Specific imports:
import Button from '@mui/material/Button'; // 50KB
function App() {
return <Button>Click</Button>;
}---
Configure Babel for Tree Shaking
.babelrc:
{
"presets": [
["@babel/preset-env", {
"modules": false // Don't transform ES modules
}]
]
}Without "modules": false, Babel converts ESM to CommonJS, breaking tree-shaking.
---
Strategy 3: Replace Heavy Dependencies
Common Swaps
| Old Library | Size | New Library | Size | Savings |
|---|---|---|---|---|
| moment.js | 288KB | date-fns | 28KB | 90% |
| lodash | 500KB | lodash-es | 5KB* | 99%* |
| axios | 50KB | native fetch | 0KB | 100% |
| react-router-dom | 45KB | wouter | 1.2KB | 97% |
| recharts | 400KB | victory-native | 50KB | 87% |
\* When importing only needed functions
---
Example: Replace moment.js with date-fns
Before:
import moment from 'moment'; // 288KB
const formatted = moment(date).format('MMM DD, YYYY');
const relative = moment(date).fromNow();After:
import { format, formatDistanceToNow } from 'date-fns'; // 28KB
const formatted = format(date, 'MMM dd, yyyy');
const relative = formatDistanceToNow(date, { addSuffix: true });Impact: -260KB (-90%)
---
Example: Replace axios with fetch
Before:
import axios from 'axios'; // 50KB
const response = await axios.get('/api/users');
const data = response.data;After:
// Native fetch (0KB)
const response = await fetch('/api/users');
const data = await response.json();Impact: -50KB (-100%)
Note: For complex use cases, consider ky (5KB) as lightweight axios alternative
---
Strategy 4: Externalize Large Dependencies
Move rarely-changing libraries to CDN:
index.html:
<!-- React from CDN -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>webpack.config.js:
module.exports = {
externals: {
react: 'React',
'react-dom': 'ReactDOM',
},
};Impact: Bundle size: 500KB → 450KB (React now loaded from CDN)
Trade-offs:
- ✅ Smaller bundle
- ✅ CDN caching across sites
- ❌ Extra HTTP request
- ❌ Dependency on CDN availability
---
Strategy 5: Remove Unused Code
Analyze with Bundle Analyzer
npx webpack-bundle-analyzer stats.jsonLook for:
- Libraries you don't remember installing
- Multiple versions of same library
- Test utilities in production bundle
- Polyfills for features you don't use
---
Example: Remove Unused Polyfills
Before (polyfills for IE11):
import 'core-js/stable'; // 200KB
import 'regenerator-runtime/runtime'; // 50KBAfter (modern browsers only):
// Remove polyfills
// Assume target: "es2020" in tsconfig.jsonImpact: -250KB if targeting modern browsers
---
Remove Development-Only Code
.babelrc (production):
{
"plugins": [
["transform-remove-console", { "exclude": ["error", "warn"] }]
]
}webpack.config.js:
module.exports = {
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify('production')
})
]
};This removes PropTypes, debug logs, and dev warnings.
---
Strategy 6: Optimize Images and Assets
Use Next-Gen Formats
| Format | Size | Browser Support |
|---|---|---|
| PNG | 100KB | All |
| JPEG | 50KB | All |
| WebP | 30KB | 96% |
| AVIF | 20KB | 89% |
Implementation:
<picture>
<source srcset="image.avif" type="image/avif">
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="Fallback">
</picture>---
Lazy Load Images
function ImageGallery({ images }) {
return (
<div>
{images.map(img => (
<img
key={img.id}
src={img.url}
loading="lazy" // Native lazy loading
alt={img.alt}
/>
))}
</div>
);
}Impact: Initial page load doesn't download off-screen images
---
Inline Small Assets
webpack.config.js:
module.exports = {
module: {
rules: [
{
test: /\.(png|jpg|gif|svg)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024 // Inline if <8KB
}
}
}
]
}
};Benefit: Small images become base64 data URLs (no extra HTTP requests)
---
Strategy 7: Compression
Enable Gzip/Brotli
Server config (nginx):
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 1000;
# Brotli (better compression)
brotli on;
brotli_types text/plain text/css application/json application/javascript;Impact: 500KB bundle → 100KB gzipped (80% reduction)
Brotli vs Gzip: Brotli ~20% smaller than Gzip for text
---
Pre-compress at Build Time
# Generate .gz and .br files at build
npx webpack --mode production
gzip -k dist/*.js
brotli dist/*.jsBenefit: Server doesn't compress on-the-fly (faster response)
---
Strategy 8: Minification
Terser (Webpack Default)
webpack.config.js:
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true, // Remove console.log
drop_debugger: true,
},
mangle: true, // Shorten variable names
},
}),
],
},
};---
CSS Minification
npm install -D css-minimizer-webpack-pluginwebpack.config.js:
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
module.exports = {
optimization: {
minimizer: [
'...', // Keep existing minimizers
new CssMinimizerPlugin(),
],
},
};---
Strategy 9: Dynamic Imports for Features
Split features that not all users need:
function App() {
const [showAdmin, setShowAdmin] = useState(false);
const loadAdminPanel = async () => {
// Only load admin code when needed
const { AdminPanel } = await import('./AdminPanel');
setShowAdmin(true);
};
return (
<div>
{user.isAdmin && (
<button onClick={loadAdminPanel}>Open Admin</button>
)}
{showAdmin && <AdminPanel />}
</div>
);
}Impact: Admin code (200KB) not loaded for regular users
---
Strategy 10: Prefetch/Preload Strategic Resources
Prefetch Next Page
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
function Home() {
useEffect(() => {
// Prefetch dashboard during idle time
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = '/dashboard.js';
document.head.appendChild(link);
}, []);
return <div>Home</div>;
}Benefit: Dashboard loads instantly when user navigates to it
---
Preload Critical Resources
<head>
<!-- Load critical font before anything else -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<!-- Preload critical CSS -->
<link rel="preload" href="/critical.css" as="style">
</head>---
Real-World Example
Problem: 2.5MB Bundle
Before:
Main bundle: 2.5MB (uncompressed)
Gzipped: 650KB
Time to Interactive: 8 seconds on 3GIssues Found: 1. Entire app in one bundle (no code splitting) 2. moment.js (288KB) for simple date formatting 3. Lodash CommonJS import (500KB) 4. Chart library always loaded (400KB) 5. Multiple polyfills (250KB) 6. No compression
Fixes Applied: 1. Route-based code splitting → -800KB 2. Replace moment.js with date-fns → -260KB 3. Use lodash-es with tree shaking → -495KB 4. Lazy load chart component → -400KB 5. Remove unnecessary polyfills → -250KB 6. Enable Brotli compression → 80% reduction
After:
Main bundle: 250KB (uncompressed)
Brotli: 50KB
Time to Interactive: 1.2 seconds on 3GResult: 8s → 1.2s (85% faster)
---
Production Checklist
□ Routes code-split with React.lazy
□ Heavy components lazy-loaded
□ Tree-shakeable imports (ES modules)
□ Replaced heavy dependencies (moment → date-fns)
□ Removed unused code (bundle analyzer checked)
□ Compression enabled (Brotli > Gzip)
□ Images optimized and lazy-loaded
□ Source maps not shipped to production
□ Minification enabled (Terser for JS, CSSMini for CSS)
□ Bundle analyzed (no duplicate dependencies)
□ Target: Main bundle <300KB gzipped
□ Target: TTI <3 seconds on 3G---
Resources
- webpack-bundle-analyzer
- Bundlephobia - Find library sizes
- Bundle.js.org - Compare bundle sizes
- Can I Use - Check browser support
React Memory Leaks Guide
Common memory leak patterns in React and how to detect and fix them.
What Are Memory Leaks?
Definition: Memory that's allocated but never freed, causing increasing memory usage over time.
Symptoms:
- Page becomes slower over time
- Browser tab crashes after extended use
- High memory usage in Task Manager
- Performance degrades after navigation
Impact:
- 100MB leak → Tab crash after 30 minutes
- 10MB leak → Noticeable slowdown after 1 hour
---
Detecting Memory Leaks
Method 1: Chrome DevTools Memory Profiler
1. Open DevTools → Memory tab
2. Take heap snapshot (baseline)
3. Interact with app (open/close components, navigate)
4. Take second snapshot
5. Compare snapshots
6. Look for objects that should have been freedWhat to look for:
- Detached DOM nodes (should be 0)
- Event listeners still attached
- Timers still running
- Components in memory after unmount
---
Method 2: Performance Monitor
1. Open DevTools → Performance Monitor
2. Watch "JS heap size"
3. Interact with app
4. Heap should return to baseline after actionsNormal: Memory spikes then drops (garbage collected) Leak: Memory increases steadily, never drops
---
Method 3: Automated Detection
// Add to development environment
if (process.env.NODE_ENV === 'development') {
let previousHeap = 0;
setInterval(() => {
const current = (performance as any).memory?.usedJSHeapSize || 0;
if (current > previousHeap * 1.5) {
console.warn('Possible memory leak detected', {
previous: previousHeap,
current,
increase: current - previousHeap
});
}
previousHeap = current;
}, 5000);
}---
Common Leak Patterns
Pattern 1: Event Listeners Not Cleaned Up
Problem: Event listener remains after component unmounts
❌ Leaking Code:
function SearchBox() {
useEffect(() => {
// Add listener
window.addEventListener('resize', handleResize);
// ❌ Missing cleanup
}, []);
return <input />;
}Why it leaks: handleResize references component, preventing garbage collection
✅ Fixed Code:
function SearchBox() {
useEffect(() => {
const handleResize = () => {
// Handle resize
};
window.addEventListener('resize', handleResize);
// ✅ Clean up on unmount
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <input />;
}---
Pattern 2: Timers Not Cleared
Problem: setInterval/setTimeout continues after unmount
❌ Leaking Code:
function LiveClock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
// Start interval
setInterval(() => {
setTime(new Date());
}, 1000);
// ❌ Interval never cleared
}, []);
return <div>{time.toLocaleTimeString()}</div>;
}Why it leaks: Interval continues forever, calling setTime on unmounted component
✅ Fixed Code:
function LiveClock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const intervalId = setInterval(() => {
setTime(new Date());
}, 1000);
// ✅ Clear interval on unmount
return () => {
clearInterval(intervalId);
};
}, []);
return <div>{time.toLocaleTimeString()}</div>;
}---
Pattern 3: Subscriptions Not Unsubscribed
Problem: WebSocket/EventEmitter/Observable subscription persists
❌ Leaking Code:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const socket = io(`/rooms/${roomId}`);
socket.on('message', (msg) => {
setMessages(prev => [...prev, msg]);
});
// ❌ Socket never disconnected
}, [roomId]);
return <MessageList messages={messages} />;
}✅ Fixed Code:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const socket = io(`/rooms/${roomId}`);
socket.on('message', (msg) => {
setMessages(prev => [...prev, msg]);
});
// ✅ Disconnect and clean up
return () => {
socket.disconnect();
};
}, [roomId]);
return <MessageList messages={messages} />;
}---
Pattern 4: State Updates on Unmounted Components
Problem: Async operation completes after unmount, tries to update state
❌ Leaking Code:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data); // ❌ Might run after unmount
});
}, [userId]);
return <div>{user?.name}</div>;
}Warning in console: "Can't perform a React state update on an unmounted component"
✅ Fixed Code (Option 1: Cleanup flag):
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let isMounted = true;
fetchUser(userId).then(data => {
if (isMounted) {
setUser(data);
}
});
return () => {
isMounted = false;
};
}, [userId]);
return <div>{user?.name}</div>;
}✅ Fixed Code (Option 2: AbortController):
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetchUser(userId, { signal: controller.signal })
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => {
controller.abort();
};
}, [userId]);
return <div>{user?.name}</div>;
}---
Pattern 5: Closures Capturing Large Objects
Problem: Callback holds reference to large data structure
❌ Leaking Code:
function DataGrid({ data }) { // data is 10MB array
const [selected, setSelected] = useState(null);
const handleClick = useCallback((id) => {
// ❌ Closure captures entire 'data' array
const item = data.find(d => d.id === id);
setSelected(item);
}, [data]); // data is in dependency array
return <Table data={data} onRowClick={handleClick} />;
}Why it leaks: Every re-render creates new function capturing 10MB data
✅ Fixed Code:
function DataGrid({ data }) {
const [selected, setSelected] = useState(null);
// Create lookup map (smaller memory footprint)
const dataMap = useMemo(() => {
return new Map(data.map(d => [d.id, d]));
}, [data]);
const handleClick = useCallback((id) => {
// ✅ Closure only captures Map reference
const item = dataMap.get(id);
setSelected(item);
}, [dataMap]);
return <Table data={data} onRowClick={handleClick} />;
}---
Pattern 6: DOM References Not Cleared
Problem: Ref holds reference to detached DOM node
❌ Leaking Code:
function ImageGallery() {
const containerRef = useRef<HTMLDivElement>(null);
const imageRefs = useRef<HTMLImageElement[]>([]);
useEffect(() => {
// Store refs to all images
imageRefs.current = Array.from(
containerRef.current?.querySelectorAll('img') || []
);
// ❌ Image refs never cleared
}, []);
return (
<div ref={containerRef}>
{/* Images render here */}
</div>
);
}✅ Fixed Code:
function ImageGallery() {
const containerRef = useRef<HTMLDivElement>(null);
const imageRefs = useRef<HTMLImageElement[]>([]);
useEffect(() => {
imageRefs.current = Array.from(
containerRef.current?.querySelectorAll('img') || []
);
// ✅ Clear refs on unmount
return () => {
imageRefs.current = [];
};
}, []);
return (
<div ref={containerRef}>
{/* Images render here */}
</div>
);
}---
Pattern 7: Global State Not Cleaned
Problem: Component adds data to global store but never removes it
❌ Leaking Code:
function UserSession({ userId }) {
useEffect(() => {
// Add user to global cache
globalCache.set(userId, fetchUser(userId));
// ❌ Never removed from cache
}, [userId]);
return <div>Session active</div>;
}Why it leaks: Cache grows indefinitely as users navigate
✅ Fixed Code:
function UserSession({ userId }) {
useEffect(() => {
globalCache.set(userId, fetchUser(userId));
// ✅ Clean up on unmount
return () => {
globalCache.delete(userId);
};
}, [userId]);
return <div>Session active</div>;
}---
Pattern 8: Third-Party Library Instances
Problem: Library instance not destroyed
❌ Leaking Code:
import mapboxgl from 'mapbox-gl';
function MapView() {
const mapRef = useRef<mapboxgl.Map | null>(null);
useEffect(() => {
mapRef.current = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v11'
});
// ❌ Map instance never destroyed
}, []);
return <div id="map" />;
}✅ Fixed Code:
import mapboxgl from 'mapbox-gl';
function MapView() {
const mapRef = useRef<mapboxgl.Map | null>(null);
useEffect(() => {
mapRef.current = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v11'
});
// ✅ Destroy map on unmount
return () => {
mapRef.current?.remove();
mapRef.current = null;
};
}, []);
return <div id="map" />;
}Common libraries that need cleanup:
- Mapbox GL:
map.remove() - Chart.js:
chart.destroy() - Monaco Editor:
editor.dispose() - Three.js:
renderer.dispose(),geometry.dispose()
---
Advanced Debugging
Using Chrome DevTools Memory Allocation Timeline
1. DevTools → Performance tab
2. Check "Memory" checkbox
3. Click record
4. Interact with app (open/close modal 10 times)
5. Stop recording
6. Look at heap size graphHealthy pattern: Sawtooth (allocate, GC, allocate, GC) Leak pattern: Steady increase (allocate, allocate, allocate)
---
Heap Snapshot Comparison
1. Take snapshot A
2. Open modal
3. Take snapshot B
4. Close modal
5. Force GC (DevTools → Memory → Collect garbage icon)
6. Take snapshot C
7. Compare B and CWhat to look for:
- Objects from modal still in snapshot C
- Event listeners still attached
- Timers still running
Filter by:
- Constructor name (e.g., "Timer", "Listener")
- Retained size (objects holding most memory)
---
React DevTools Profiler
1. React DevTools → Profiler
2. Enable "Record why each component rendered"
3. Navigate to page
4. Navigate away
5. Force GC
6. Check if components still in memory---
Testing for Memory Leaks
Automated Test (Jest + Puppeteer)
import puppeteer from 'puppeteer';
test('Modal does not leak memory', async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('http://localhost:3000');
// Take baseline heap size
const baseline = await page.evaluate(() => {
return (performance as any).memory.usedJSHeapSize;
});
// Open/close modal 20 times
for (let i = 0; i < 20; i++) {
await page.click('[data-testid="open-modal"]');
await page.waitForSelector('[data-testid="modal"]');
await page.click('[data-testid="close-modal"]');
await page.waitForSelector('[data-testid="modal"]', { hidden: true });
}
// Force garbage collection
await page.evaluate(() => {
if ((window as any).gc) {
(window as any).gc();
}
});
// Check final heap size
const final = await page.evaluate(() => {
return (performance as any).memory.usedJSHeapSize;
});
// Memory should not increase by more than 10MB
const increase = final - baseline;
expect(increase).toBeLessThan(10 * 1024 * 1024);
await browser.close();
});Run with:
node --expose-gc node_modules/.bin/jest memory.test.ts---
Prevention Checklist
□ All event listeners cleaned up in useEffect return
□ All timers (setTimeout/setInterval) cleared
□ All subscriptions (WebSocket, EventEmitter) closed
□ AbortController used for fetch requests
□ Third-party library instances destroyed (.remove(), .destroy(), .dispose())
□ Global state cleaned up on unmount
□ Large objects not captured in closures
□ DOM refs cleared on unmount
□ Tested with heap snapshots (no growth after actions)
□ Automated memory leak test in CI---
Common Libraries and Cleanup
| Library | Cleanup Method |
|---|---|
| Socket.IO | socket.disconnect() |
| RxJS | subscription.unsubscribe() |
| Chart.js | chart.destroy() |
| Mapbox GL | map.remove() |
| Monaco Editor | editor.dispose() |
| Three.js | renderer.dispose(), geometry.dispose(), material.dispose() |
| Video.js | player.dispose() |
| Swiper | swiper.destroy() |
---
Real-World Example
Problem: Dashboard Leaking 50MB per Navigation
Symptoms:
- Page slow after 5-10 navigation cycles
- Chrome DevTools shows heap growing from 50MB → 500MB
- Tab crashes after 15 minutes
Investigation: 1. Heap snapshot comparison revealed:
- 1000+ event listeners still attached
- Chart.js instances not destroyed
- WebSocket connections not closed
Leaking Code:
function Dashboard() {
const chartRef = useRef<Chart | null>(null);
useEffect(() => {
// Create chart
chartRef.current = new Chart(ctx, config);
// Subscribe to updates
socket.on('data', updateChart);
// Add resize listener
window.addEventListener('resize', handleResize);
// ❌ Nothing cleaned up
}, []);
return <canvas ref={canvasRef} />;
}Fixed Code:
function Dashboard() {
const chartRef = useRef<Chart | null>(null);
useEffect(() => {
chartRef.current = new Chart(ctx, config);
socket.on('data', updateChart);
window.addEventListener('resize', handleResize);
// ✅ Clean up everything
return () => {
chartRef.current?.destroy();
socket.off('data', updateChart);
window.removeEventListener('resize', handleResize);
};
}, []);
return <canvas ref={canvasRef} />;
}Result:
- Memory stable at ~50MB regardless of navigation
- No crashes after extended use
- Page remains fast
---
Resources
React DevTools Profiler Guide
Complete guide to identifying and fixing performance issues using React DevTools Profiler.
Installation
# Chrome Extension
https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi
# Firefox Add-on
https://addons.mozilla.org/en-US/firefox/addon/react-devtools/
# Standalone
npm install -g react-devtools---
The Profiler Tab
Where to Find It
1. Open React DevTools (browser extension or standalone) 2. Click the Profiler tab 3. You'll see:
- ⏺️ Record button - Start/stop profiling
- 🔄 Reload and profile button - Profile page load
- ⚙️ Settings gear - Configure profiling options
---
Recording a Profile
Method 1: User Interaction
1. Click ⏺️ Record
2. Interact with your app (click button, type, scroll, etc.)
3. Click ⏹️ Stop
4. Analyze the flame graphMethod 2: Page Load
1. Click 🔄 Reload and profile
2. Wait for page to fully load
3. Profiler automatically stops
4. Analyze initial render performance---
Reading the Flame Graph
What Each Color Means
Colors (gradient from green to yellow to red):
- 🟢 Green: Fast render (<1ms)
- 🟡 Yellow: Moderate render (1-10ms)
- 🔴 Red: Slow render (>10ms)
Width: How long the component took to render
Hover: See exact render time and why it rendered
Example Interpretation
App (25ms)
├── Navbar (2ms) ✅ Fast
├── Sidebar (1ms) ✅ Fast
└── Dashboard (22ms) 🔴 SLOW - investigate!
├── UserList (20ms) 🔴 SLOW - root cause
└── Stats (2ms) ✅ FastDiagnosis: UserList is the bottleneck (20ms of 25ms total)
---
Understanding "Why Did This Render?"
Click on a component in the flame graph to see:
1. Props Changed
Why did this render?
• Props changed: { userId: 123 }Fix: If props didn't actually change, wrap parent in React.memo or memoize the prop value.
2. Parent Rendered
Why did this render?
• Parent component renderedFix: Wrap this component in React.memo to prevent cascading re-renders.
3. State Changed
Why did this render?
• Hook 1 changedFix: This is expected. Ensure state updates are necessary.
4. Context Changed
Why did this render?
• Context changedFix: Split context into smaller pieces or memoize context value.
---
Common Patterns and Fixes
Pattern 1: Entire Tree Re-renders on Unrelated State Change
Symptom: Changing state in Header causes Footer to re-render
Flame Graph:
App (50ms) 🔴
├── Header (2ms) 🟡
├── Content (40ms) 🔴 Unnecessary
└── Footer (8ms) 🔴 UnnecessaryFix: Wrap components in React.memo
const Header = React.memo(({ title }) => {
return <header>{title}</header>;
});
const Content = React.memo(({ children }) => {
return <main>{children}</main>;
});
const Footer = React.memo(() => {
return <footer>Footer</footer>;
});Result: Only Header re-renders
App (2ms) 🟢
└── Header (2ms) 🟢---
Pattern 2: List Renders Slowly
Symptom: Rendering 1000-item list takes >500ms
Flame Graph:
UserList (500ms) 🔴
├── UserCard (0.5ms) x1000Fix: Virtualize with react-window
import { FixedSizeList } from 'react-window';
function UserList({ users }) {
return (
<FixedSizeList
height={600}
itemCount={users.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<UserCard user={users[index]} />
</div>
)}
</FixedSizeList>
);
}Result: Render time drops to <50ms
UserList (50ms) 🟢
└── UserCard (0.5ms) x12 (only visible items)---
Pattern 3: Parent Passes New Callback on Every Render
Symptom: Child wrapped in React.memo still re-renders
Flame Graph:
Parent (30ms)
└── Child (28ms) 🔴 Re-renders despite React.memoWhy: Parent passes new function reference
// ❌ Creates new function every render
function Parent() {
return <Child onClick={() => console.log('clicked')} />;
}Fix: Memoize callback with useCallback
// ✅ Stable function reference
function Parent() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <Child onClick={handleClick} />;
}
const Child = React.memo(({ onClick }) => {
return <button onClick={onClick}>Click me</button>;
});Result: Child no longer re-renders
---
Pattern 4: Expensive Calculation on Every Render
Symptom: Component takes 100ms but doesn't render anything complex
Flame Graph:
ProductList (100ms) 🔴
└── (no children, just slow)Code:
function ProductList({ products }) {
// ❌ Sorts on every render
const sorted = products.sort((a, b) => b.price - a.price);
return <div>{sorted.map(p => <Product product={p} />)}</div>;
}Fix: Memoize expensive calculation
function ProductList({ products }) {
// ✅ Only re-sorts when products change
const sorted = useMemo(
() => [...products].sort((a, b) => b.price - a.price),
[products]
);
return <div>{sorted.map(p => <Product product={p} />)}</div>;
}Result: Render time drops from 100ms → 5ms
---
Profiler Settings
Record Why Components Rendered
Enable: Settings ⚙️ → "Record why each component rendered while profiling"
Benefit: See exact cause (props, state, context, parent)
Trade-off: Slight performance overhead during profiling
Highlight Updates
Enable: Settings ⚙️ → "Highlight updates when components render"
Benefit: See which components re-render in real-time (blue flash)
Use case: Quickly spot unnecessary re-renders without profiling
---
Profiling Production Builds
Why Profile Production?
- Development mode is 2-5x slower (extra checks, warnings)
- Only production profiling shows real user experience
- Tree-shaking and minification affect bundle size
How to Profile Production
Option 1: Build with profiling enabled
# React (webpack)
npx react-app-rewired build --profile
# Next.js
NEXT_PUBLIC_PROFILING=true npm run buildOption 2: Use profiling build
// Use production build with profiling
import { unstable_trace as trace } from 'scheduler/tracing';---
Metrics to Target
| Metric | Excellent | Good | Needs Work |
|---|---|---|---|
| Component render time | <5ms | 5-16ms | >16ms (visible lag) |
| Total page load | <1s | 1-3s | >3s |
| Interaction response | <100ms | 100-300ms | >300ms (feels slow) |
| List item render | <1ms | 1-5ms | >5ms (virtualize) |
The 16ms Rule: 60 FPS = 16.67ms per frame. Renders >16ms cause dropped frames.
---
Real-World Example
Problem: Slow Dashboard
Flame Graph:
Dashboard (180ms) 🔴
├── Sidebar (2ms)
├── Header (3ms)
└── DataTable (175ms) 🔴
├── TableHeader (2ms)
└── TableBody (173ms) 🔴
├── Row (0.17ms) x1000Diagnosis: 1. DataTable is bottleneck (175ms of 180ms) 2. Rendering 1000 rows (0.17ms each = 170ms total) 3. Even though each row is fast, rendering 1000 is slow
Fix:
import { FixedSizeList } from 'react-window';
function DataTable({ rows }) {
return (
<FixedSizeList
height={600}
itemCount={rows.length}
itemSize={40}
>
{({ index, style }) => (
<Row style={style} data={rows[index]} />
)}
</FixedSizeList>
);
}Result:
Dashboard (15ms) 🟢
├── Sidebar (2ms)
├── Header (3ms)
└── DataTable (10ms) 🟢
└── Row (0.17ms) x15 (only visible rows)Improvement: 180ms → 15ms (92% faster!)
---
Debugging Workflow
1. Identify slow component
- Record profile
- Look for red/yellow bars in flame graph
- Click to see render time
2. Understand why it's slow
- Check "Why did this render?"
- Is it rendering unnecessarily?
- Is the render itself expensive?
3. Apply fix
- Unnecessary re-renders? → React.memo, useCallback
- Expensive calculation? → useMemo
- Large list? → Virtualization
- Heavy component? → Code splitting with React.lazy
4. Verify improvement
- Record new profile
- Compare before/after
- Target: Green bars, <16ms render time
---
Common Mistakes
Mistake 1: Profiling in Development
Problem: Dev mode is 2-5x slower than production
Solution: Always profile production builds for accurate measurements
Mistake 2: Optimizing Green Components
Problem: Spending time memoizing fast components (<5ms)
Solution: Focus on red/yellow bars first (>10ms)
Mistake 3: Ignoring "Why Did This Render?"
Problem: Blindly adding useMemo/useCallback everywhere
Solution: Click component to see actual render cause, then fix root issue
Mistake 4: Not Testing After Changes
Problem: Optimization doesn't actually improve performance
Solution: Record before/after profiles to verify improvement
---
Advanced Techniques
Custom Profiler API
Programmatically measure render performance:
import { Profiler } from 'react';
function onRenderCallback(
id, // "DataTable"
phase, // "mount" or "update"
actualDuration, // Time spent rendering
baseDuration, // Estimated time without memoization
startTime, // When render started
commitTime, // When render committed
interactions // Set of interactions
) {
console.log(`${id} took ${actualDuration}ms to render`);
// Send to analytics
if (actualDuration > 16) {
analytics.track('Slow Render', { id, actualDuration });
}
}
function App() {
return (
<Profiler id="DataTable" onRender={onRenderCallback}>
<DataTable />
</Profiler>
);
}User Timing API
Add custom markers:
function DataTable() {
performance.mark('data-table-render-start');
// Rendering logic
performance.mark('data-table-render-end');
performance.measure(
'DataTable Render',
'data-table-render-start',
'data-table-render-end'
);
return <div>...</div>;
}View in Performance tab of browser DevTools.
---
Resources
#!/bin/bash
# Bundle Analyzer Script
#
# Analyzes webpack bundle to identify large dependencies and optimization opportunities.
#
# Usage:
# ./bundle_analyzer.sh
# ./bundle_analyzer.sh --no-open # Don't open browser
# ./bundle_analyzer.sh --json # Generate JSON report
#
# Requirements:
# npm install -D webpack-bundle-analyzer
#
# For Next.js projects, install @next/bundle-analyzer instead
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
OPEN_BROWSER=true
JSON_MODE=false
BUILD_DIR="dist"
STATS_FILE="stats.json"
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--no-open)
OPEN_BROWSER=false
shift
;;
--json)
JSON_MODE=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: ./bundle_analyzer.sh [--no-open] [--json]"
exit 1
;;
esac
done
echo -e "${BLUE}📦 Bundle Analyzer${NC}\n"
# Detect project type
if [ -f "next.config.js" ] || [ -f "next.config.mjs" ]; then
PROJECT_TYPE="nextjs"
echo -e "${GREEN}Detected: Next.js project${NC}"
elif [ -f "vite.config.ts" ] || [ -f "vite.config.js" ]; then
PROJECT_TYPE="vite"
echo -e "${GREEN}Detected: Vite project${NC}"
elif grep -q "react-scripts" package.json 2>/dev/null; then
PROJECT_TYPE="cra"
echo -e "${GREEN}Detected: Create React App${NC}"
else
PROJECT_TYPE="webpack"
echo -e "${GREEN}Detected: Webpack project${NC}"
fi
# Function to analyze Next.js bundle
analyze_nextjs() {
echo -e "\n${YELLOW}Setting up Next.js bundle analyzer...${NC}"
# Check if analyzer is installed
if ! grep -q "@next/bundle-analyzer" package.json; then
echo -e "${YELLOW}Installing @next/bundle-analyzer...${NC}"
npm install -D @next/bundle-analyzer
fi
# Create or update next.config.js
if [ -f "next.config.js" ]; then
echo -e "${YELLOW}Backup existing next.config.js...${NC}"
cp next.config.js next.config.js.backup
fi
# Add analyzer to config
cat > next.config.analyzer.js << 'EOF'
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
const nextConfig = {
// Your existing config here
}
module.exports = withBundleAnalyzer(nextConfig)
EOF
echo -e "${GREEN}Running Next.js build with analyzer...${NC}\n"
ANALYZE=true npm run build
echo -e "\n${GREEN}✅ Analysis complete!${NC}"
echo -e "Reports generated in .next/analyze/"
}
# Function to analyze Vite bundle
analyze_vite() {
echo -e "\n${YELLOW}Setting up Vite bundle analyzer...${NC}"
# Check if plugin is installed
if ! grep -q "rollup-plugin-visualizer" package.json; then
echo -e "${YELLOW}Installing rollup-plugin-visualizer...${NC}"
npm install -D rollup-plugin-visualizer
fi
# Add to vite.config.ts
echo -e "${YELLOW}Add visualizer plugin to vite.config.ts:${NC}"
cat << 'EOF'
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({
filename: 'dist/stats.html',
open: true,
gzipSize: true,
brotliSize: true
})
]
});
EOF
echo -e "\n${YELLOW}Run: npm run build${NC}"
echo -e "Then open: dist/stats.html\n"
}
# Function to analyze CRA bundle
analyze_cra() {
echo -e "\n${YELLOW}Setting up CRA bundle analyzer...${NC}"
# Install analyzer
if ! grep -q "source-map-explorer" package.json; then
echo -e "${YELLOW}Installing source-map-explorer...${NC}"
npm install -D source-map-explorer
fi
# Add script to package.json
echo -e "${YELLOW}Add to package.json scripts:${NC}"
cat << 'EOF'
"analyze": "source-map-explorer 'build/static/js/*.js'"
EOF
echo -e "\n${GREEN}Running production build...${NC}\n"
npm run build
echo -e "\n${GREEN}Analyzing bundle...${NC}\n"
npm run analyze
}
# Function to analyze webpack bundle
analyze_webpack() {
echo -e "\n${YELLOW}Setting up webpack bundle analyzer...${NC}"
# Check if analyzer is installed
if ! grep -q "webpack-bundle-analyzer" package.json; then
echo -e "${YELLOW}Installing webpack-bundle-analyzer...${NC}"
npm install -D webpack-bundle-analyzer
fi
# Generate stats
echo -e "\n${GREEN}Building with stats...${NC}\n"
if [ -f "webpack.config.js" ]; then
npx webpack --profile --json > stats.json
else
npm run build -- --stats
fi
# Run analyzer
if [ -f "stats.json" ]; then
echo -e "\n${GREEN}Opening bundle analyzer...${NC}\n"
if [ "$OPEN_BROWSER" = true ]; then
npx webpack-bundle-analyzer stats.json
else
npx webpack-bundle-analyzer stats.json --no-open
fi
else
echo -e "${RED}stats.json not found. Build may have failed.${NC}"
exit 1
fi
}
# Main execution
case $PROJECT_TYPE in
nextjs)
analyze_nextjs
;;
vite)
analyze_vite
;;
cra)
analyze_cra
;;
webpack)
analyze_webpack
;;
esac
# Print optimization tips
echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE}📊 Bundle Optimization Tips${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
echo -e "${YELLOW}Look for:${NC}"
echo -e " 🔍 Large dependencies (>100KB)"
echo -e " 🔍 Duplicate dependencies (different versions)"
echo -e " 🔍 Unused dependencies"
echo -e " 🔍 Development dependencies in production bundle"
echo ""
echo -e "${YELLOW}Common fixes:${NC}"
echo -e " ✅ Code splitting: Use dynamic import() for routes"
echo -e " ✅ Tree-shaking: Use ESM imports (import { x } from 'lib')"
echo -e " ✅ Smaller alternatives:"
echo -e " - moment.js → date-fns (10x smaller)"
echo -e " - lodash → lodash-es (tree-shakeable)"
echo -e " - axios → native fetch"
echo -e " ✅ Externalize: Move large libs to CDN"
echo -e " ✅ Lazy load: Use React.lazy() for components"
echo ""
echo -e "${YELLOW}Target bundle sizes (gzipped):${NC}"
echo -e " 🎯 Excellent: <100KB"
echo -e " ⚠️ Good: 100-300KB"
echo -e " 🔴 Large: >300KB (needs optimization)"
echo ""
# Calculate current bundle size
if [ -d "$BUILD_DIR/static/js" ]; then
TOTAL_SIZE=$(du -sh "$BUILD_DIR/static/js" | cut -f1)
echo -e "${GREEN}Current JS bundle size: $TOTAL_SIZE${NC}"
elif [ -d ".next/static" ]; then
TOTAL_SIZE=$(du -sh ".next/static" | cut -f1)
echo -e "${GREEN}Current bundle size: $TOTAL_SIZE${NC}"
fi
echo ""
#!/usr/bin/env node
/**
* React Performance Audit Tool
*
* Analyzes React components for common performance issues.
*
* Usage: npx tsx performance_audit.ts [directory]
*
* Examples:
* npx tsx performance_audit.ts src/components
* npx tsx performance_audit.ts src/
*
* Checks for:
* - Missing React.memo on expensive components
* - Inline function definitions in JSX
* - Missing useCallback/useMemo
* - Large lists without virtualization
* - Expensive operations in render
*/
import * as fs from 'fs';
import * as path from 'path';
interface PerformanceIssue {
file: string;
line: number;
severity: 'critical' | 'warning' | 'info';
type: string;
message: string;
fix?: string;
}
class PerformanceAuditor {
private issues: PerformanceIssue[] = [];
private fileCount = 0;
private componentCount = 0;
auditDirectory(dir: string): void {
const files = this.getReactFiles(dir);
files.forEach(file => {
this.auditFile(file);
});
}
private getReactFiles(dir: string): string[] {
const files: string[] = [];
const walk = (currentDir: string) => {
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
entries.forEach(entry => {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
// Skip node_modules, build, dist
if (!['node_modules', 'build', 'dist', '.next'].includes(entry.name)) {
walk(fullPath);
}
} else if (entry.isFile()) {
// React files: .tsx, .jsx
if (/\.(tsx|jsx)$/.test(entry.name)) {
files.push(fullPath);
}
}
});
};
walk(dir);
return files;
}
private auditFile(filePath: string): void {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
this.fileCount++;
// Check for component definitions
const componentMatches = content.match(/(?:function|const)\s+([A-Z][a-zA-Z0-9]*)/g);
if (componentMatches) {
this.componentCount += componentMatches.length;
}
// Check each line
lines.forEach((line, index) => {
const lineNumber = index + 1;
// Critical: Inline arrow functions in JSX
if (this.hasInlineFunction(line) && this.isJSXLine(line)) {
this.addIssue(filePath, lineNumber, 'critical', 'inline-function',
'Inline arrow function in JSX creates new reference on every render',
'Extract to useCallback or define outside render');
}
// Critical: Large array.map without virtualization
if (this.hasUnvirtualizedList(line)) {
this.addIssue(filePath, lineNumber, 'critical', 'unvirtualized-list',
'Rendering large list without virtualization',
'Use react-window or react-virtualized for lists >100 items');
}
// Warning: Component not wrapped in React.memo
if (this.isPureComponent(line, content) && !this.hasMemo(content)) {
this.addIssue(filePath, lineNumber, 'warning', 'missing-memo',
'Pure component could benefit from React.memo',
'Wrap component in React.memo to prevent unnecessary re-renders');
}
// Warning: Expensive computation in render
if (this.hasExpensiveOperation(line) && !this.hasUseMemo(content, line)) {
this.addIssue(filePath, lineNumber, 'warning', 'expensive-render',
'Expensive operation in render without memoization',
'Wrap in useMemo to avoid recomputing on every render');
}
// Info: useState for derived state
if (this.hasDerivedState(line, lines, index)) {
this.addIssue(filePath, lineNumber, 'info', 'derived-state',
'Derived state should be computed from props/state, not stored',
'Calculate during render or use useMemo if expensive');
}
});
}
private hasInlineFunction(line: string): boolean {
// Matches: onClick={() => ...}, onChange={(e) => ...}
return /\w+={(?:\([^)]*\)|[a-z])\s*=>/i.test(line);
}
private isJSXLine(line: string): boolean {
return /<[A-Z]/.test(line) || /<[a-z]+\s/.test(line);
}
private hasUnvirtualizedList(line: string): boolean {
// Matches: {items.map(...)} without FixedSizeList/VariableSizeList
return /\{\s*\w+\.map\(/.test(line) &&
!/FixedSizeList|VariableSizeList|VirtualList/.test(line);
}
private isPureComponent(line: string, content: string): boolean {
// Component that only depends on props (no hooks, no state)
const match = line.match(/(?:function|const)\s+([A-Z][a-zA-Z0-9]*)/);
if (!match) return false;
const componentName = match[1];
const componentBody = this.extractComponentBody(content, componentName);
// Has no useState, useReducer, useContext
return !/use(?:State|Reducer|Context)/.test(componentBody);
}
private hasMemo(content: string): boolean {
return /React\.memo|memo\(/.test(content);
}
private hasExpensiveOperation(line: string): boolean {
// Matches: .sort(), .filter().map(), new Date(), JSON.parse()
return /\.sort\(|\.filter\([^)]+\)\.map\(|new Date\(|JSON\.parse\(/.test(line);
}
private hasUseMemo(content: string, line: string): boolean {
// Check if this operation is already wrapped in useMemo
const lines = content.split('\n');
const currentIndex = lines.indexOf(line);
// Look backwards for useMemo within 5 lines
for (let i = Math.max(0, currentIndex - 5); i < currentIndex; i++) {
if (/useMemo\(/.test(lines[i])) {
return true;
}
}
return false;
}
private hasDerivedState(line: string, lines: string[], index: number): boolean {
// Matches: const [x, setX] = useState(computeFromProps(props))
if (!/useState\(/.test(line)) return false;
const stateInitializer = line.match(/useState\(([^)]+)\)/)?.[1];
if (!stateInitializer) return false;
// If initializer calls a function with props/state, it's likely derived
return /(?:props|state)\.\w+/.test(stateInitializer);
}
private extractComponentBody(content: string, componentName: string): string {
const regex = new RegExp(`(?:function|const)\\s+${componentName}[^{]*{([^}]+)}`, 's');
const match = content.match(regex);
return match ? match[1] : '';
}
private addIssue(
file: string,
line: number,
severity: PerformanceIssue['severity'],
type: string,
message: string,
fix?: string
): void {
this.issues.push({ file, line, severity, type, message, fix });
}
report(): void {
console.log('\n⚡ React Performance Audit Report\n');
console.log('─'.repeat(80));
console.log(`\nScanned ${this.fileCount} files, found ${this.componentCount} components\n`);
if (this.issues.length === 0) {
console.log('✅ No performance issues detected!\n');
return;
}
const critical = this.issues.filter(i => i.severity === 'critical');
const warnings = this.issues.filter(i => i.severity === 'warning');
const info = this.issues.filter(i => i.severity === 'info');
console.log(`Found ${critical.length} critical, ${warnings.length} warnings, ${info.length} suggestions\n`);
if (critical.length > 0) {
console.log('🔴 Critical Issues (fix immediately):\n');
this.printIssues(critical);
}
if (warnings.length > 0) {
console.log('⚠️ Warnings (fix for better performance):\n');
this.printIssues(warnings);
}
if (info.length > 0) {
console.log('💡 Suggestions (consider optimizing):\n');
this.printIssues(info);
}
console.log('─'.repeat(80));
this.printSummary();
}
private printIssues(issues: PerformanceIssue[]): void {
// Group by file
const byFile = new Map<string, PerformanceIssue[]>();
issues.forEach(issue => {
if (!byFile.has(issue.file)) {
byFile.set(issue.file, []);
}
byFile.get(issue.file)!.push(issue);
});
byFile.forEach((fileIssues, file) => {
console.log(`📄 ${file}`);
fileIssues.forEach(issue => {
console.log(` Line ${issue.line}: ${issue.message}`);
if (issue.fix) {
console.log(` 💡 Fix: ${issue.fix}`);
}
});
console.log('');
});
}
private printSummary(): void {
console.log('\n📊 Summary by Issue Type:\n');
const typeCount = new Map<string, number>();
this.issues.forEach(issue => {
typeCount.set(issue.type, (typeCount.get(issue.type) || 0) + 1);
});
const sortedTypes = Array.from(typeCount.entries())
.sort((a, b) => b[1] - a[1]);
sortedTypes.forEach(([type, count]) => {
const icon = this.getIconForType(type);
console.log(`${icon} ${type}: ${count}`);
});
console.log('\n🎯 Recommended Actions:\n');
console.log('1. Fix all critical issues first (inline functions, unvirtualized lists)');
console.log('2. Profile with React DevTools to confirm impact');
console.log('3. Add React.memo to components that re-render frequently');
console.log('4. Run bundle analyzer to identify code splitting opportunities');
console.log('');
}
private getIconForType(type: string): string {
const icons: Record<string, string> = {
'inline-function': '🔥',
'unvirtualized-list': '📋',
'missing-memo': '🔄',
'expensive-render': '⏱️',
'derived-state': '💾'
};
return icons[type] || '📌';
}
}
// CLI entry point
if (require.main === module) {
const args = process.argv.slice(2);
const dir = args[0] || 'src';
if (!fs.existsSync(dir)) {
console.error(`❌ Directory not found: ${dir}`);
console.error('\nUsage: npx tsx performance_audit.ts [directory]');
console.error('Example: npx tsx performance_audit.ts src/components');
process.exit(1);
}
const auditor = new PerformanceAuditor();
auditor.auditDirectory(dir);
auditor.report();
}
export { PerformanceAuditor };