Optimizing application performance
Scope: Bundle optimization, code splitting, image optimization, Core Web Vitals, lazy loading Lines: ~310 Last Updated: 2025-10-18
Activate this skill when:
LCP (Largest Contentful Paint) - Loading performance
FID (First Input Delay) - Interactivity
CLS (Cumulative Layout Shift) - Visual stability
INP (Interaction to Next Paint) - Replacing FID
# Next.js
npm run build
# Output shows bundle sizes
# Webpack Bundle Analyzer
npm install --save-dev webpack-bundle-analyzer
# Vite
npm run build
vite-bundle-visualizer
Route-based splitting (automatic in Next.js):
// app/dashboard/page.tsx
// Automatically code-split by route
export default function Dashboard() {
return <div>Dashboard</div>;
}
Component-based splitting:
import { lazy, Suspense } from 'react';
// Lazy load heavy component
const HeavyChart = lazy(() => import('./components/HeavyChart'));
function Dashboard() {
return (
<div>
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
</div>
);
}
Dynamic imports:
// Load library only when needed
async function handleExport() {
const { exportToPDF } = await import('./lib/pdf-export');
exportToPDF(data);
}
// ❌ Bad: Imports entire library
import _ from 'lodash';
_.debounce(fn, 300);
// ✅ Good: Import only what you need
import debounce from 'lodash/debounce';
debounce(fn, 300);
// ✅ Better: Use modern alternative
import { debounce } from 'es-toolkit';
// ❌ Bad: Importing unused code
import { Button, Card, Table, Modal, Tabs } from 'ui-library';
// Only using Button
// ✅ Good: Only import what's used
import { Button } from 'ui-library';
Tools:
no-unused-varssideEffects: false in package.jsonimport Image from 'next/image';
// ❌ Bad: Regular img tag
<img src="/photo.jpg" alt="Photo" />
// ✅ Good: Next.js Image (automatic optimization)
<Image
src="/photo.jpg"
alt="Photo"
width={800}
height={600}
priority // Load eagerly (above fold)
/>
// Responsive image
<Image
src="/photo.jpg"
alt="Photo"
fill
sizes="(max-width: 768px) 100vw, 50vw"
style={{ objectFit: 'cover' }}
/>
Benefits:
Format | Use Case | Savings vs PNG
----------|--------------------|--------------
WebP | General purpose | 25-35%
AVIF | Modern browsers | 50%
PNG | Transparency | Baseline
JPEG | Photos | Smaller than PNG
SVG | Icons, logos | Scalable
// Images (native)
<img src="photo.jpg" loading="lazy" alt="Photo" />
// Next.js Image (lazy by default)
<Image src="/photo.jpg" alt="Photo" width={800} height={600} />
// Eager loading (above fold)
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />
# Compress images
npx @squoosh/cli --webp auto *.jpg
# Optimize SVGs
npx svgo *.svg
# Generate responsive images
npx sharp-cli resize 800 600 --input photo.jpg --output photo-800.jpg
import { debounce } from 'es-toolkit';
function SearchInput() {
const [query, setQuery] = useState('');
// Debounce API calls
const debouncedSearch = useMemo(
() => debounce((value: string) => {
fetch(`/api/search?q=${value}`);
}, 300),
[]
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
};
return <input value={query} onChange={handleChange} />;
}
import { throttle } from 'es-toolkit';
function ScrollTracker() {
const handleScroll = useMemo(
() => throttle(() => {
console.log('Scroll position:', window.scrollY);
}, 100),
[]
);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
return <div>Content</div>;
}
import { memo, useMemo, useCallback } from 'react';
// Memoize expensive component
const ExpensiveList = memo(function ExpensiveList({ items }: { items: Item[] }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
// Memoize expensive computation
function ProductList({ products }: { products: Product[] }) {
const sortedProducts = useMemo(() => {
return [...products].sort((a, b) => b.price - a.price);
}, [products]);
return <div>{sortedProducts.map(p => <div key={p.id}>{p.name}</div>)}</div>;
}
// Memoize callbacks
function TodoList({ todos }: { todos: Todo[] }) {
const [filter, setFilter] = useState('all');
const handleToggle = useCallback((id: string) => {
// Toggle todo logic
}, []);
return (
<div>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
))}
</div>
);
}
When to memoize:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated row height
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
</div>
);
}
Use when:
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
{/* Inline critical CSS */}
<style dangerouslySetInnerHTML={{ __html: criticalCSS }} />
</head>
<body>{children}</body>
</html>
);
}
// ❌ Bad: Inline styles (no caching, creates new object every render)
<div style={{ color: 'red', fontSize: 16 }}>Text</div>
// ✅ Good: CSS modules or Tailwind (cached, reusable)
<div className="text-red-500 text-base">Text</div>
// ✅ Good: Extract constant styles
const styles = { color: 'red', fontSize: 16 };
<div style={styles}>Text</div>
// Tailwind CSS (automatic purging)
// tailwind.config.js
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
],
// Purges unused classes in production
};
import Link from 'next/link';
// Next.js prefetches linked pages on hover
<Link href="/dashboard" prefetch={true}>
Dashboard
</Link>
// Manual prefetch
import { useRouter } from 'next/navigation';
function NavItem() {
const router = useRouter();
return (
<button
onMouseEnter={() => router.prefetch('/dashboard')}
onClick={() => router.push('/dashboard')}
>
Dashboard
</button>
);
}
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
{/* Preconnect to external domains */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://api.example.com" />
{/* DNS prefetch */}
<link rel="dns-prefetch" href="https://cdn.example.com" />
{/* Preload critical resources */}
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
</head>
<body>{children}</body>
</html>
);
}
// Next.js fetch with caching
async function getData() {
// Static (cached forever)
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache'
});
// Dynamic (no cache)
const res = await fetch('https://api.example.com/data', {
cache: 'no-store'
});
// Revalidate (time-based)
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 60 } // Revalidate every 60 seconds
});
return res.json();
}
// ✅ Server Component (default)
async function PostList() {
const posts = await getPosts(); // Fetches on server
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
// ✅ Client Component (when needed)
'use client';
import { useState } from 'react';
function InteractiveButton() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Server Components benefits:
import { Suspense } from 'react';
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
{/* Fast component renders immediately */}
<UserGreeting />
{/* Slow component streams in */}
<Suspense fallback={<Spinner />}>
<SlowAnalytics />
</Suspense>
<Suspense fallback={<Spinner />}>
<SlowRecentOrders />
</Suspense>
</div>
);
}
// app/components/WebVitals.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric);
// Send to analytics
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(metric),
});
});
return null;
}
// app/layout.tsx
import { WebVitals } from './components/WebVitals';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<WebVitals />
</body>
</html>
);
}
// React DevTools Profiler
import { Profiler } from 'react';
function onRenderCallback(
id: string,
phase: 'mount' | 'update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) {
console.log({ id, phase, actualDuration });
}
<Profiler id="Dashboard" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
// Measure custom metrics
performance.mark('data-fetch-start');
await fetchData();
performance.mark('data-fetch-end');
performance.measure('data-fetch', 'data-fetch-start', 'data-fetch-end');
const measure = performance.getEntriesByName('data-fetch')[0];
console.log('Fetch took:', measure.duration, 'ms');
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
);
}
// globals.css
body {
font-family: var(--font-inter), sans-serif;
}
code {
font-family: var(--font-roboto-mono), monospace;
}
Benefits:
/* font-display: swap - Show fallback, swap when loaded */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2');
font-display: swap;
}
/* font-display: optional - Use only if cached */
@font-face {
font-family: 'OptionalFont';
src: url('/fonts/optional.woff2');
font-display: optional;
}
LCP (Largest Contentful Paint): < 2.5s
FID (First Input Delay): < 100ms
CLS (Cumulative Layout Shift): < 0.1
INP (Interaction to Next Paint): < 200ms
[ ] Code splitting (route-based, component-based)
[ ] Image optimization (Next.js Image, WebP/AVIF)
[ ] Lazy loading (images, components)
[ ] Bundle size < 200KB (initial load)
[ ] Memoization (React.memo, useMemo, useCallback)
[ ] Virtual lists (for >100 items)
[ ] Remove unused code (tree shaking)
[ ] Prefetch linked pages
[ ] Resource hints (preconnect, dns-prefetch)
[ ] Font optimization (self-hosted, subsetting)
[ ] Critical CSS inlined
[ ] Streaming and Suspense
[ ] Server Components (where possible)
Chrome DevTools Lighthouse
WebPageTest
PageSpeed Insights
Next.js Bundle Analyzer
React DevTools Profiler
Web Vitals Chrome Extension
❌ Large bundle sizes: Split code, remove unused dependencies ✅ Analyze and optimize bundle
❌ Unoptimized images: Use Next.js Image component ✅ Automatic optimization
❌ Blocking scripts: Use async/defer or move to end of body ✅ Non-blocking script loading
❌ No lazy loading: Load everything upfront ✅ Lazy load non-critical content
❌ Premature optimization: Optimize without measuring ✅ Profile first, then optimize
react-component-patterns.md - Memoization, code splittingnextjs-app-router.md - Server Components, streamingreact-data-fetching.md - Caching, prefetchingweb-accessibility.md - Performance affects accessibilityLast Updated: 2025-10-18 Format Version: 1.0 (Atomic)