Frontend Performance Optimization
Help users diagnose and fix performance issues in Next.js/React applications with Tailwind CSS. Focus on high-impact changes rather than micro-optimizations.
Core Web Vitals
Google's Core Web Vitals are the three metrics that matter most for real-world user experience:
| Metric | What it measures | Good | Needs work | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Loading — how fast the main content appears | < 2.5s | 2.5–4s | > 4s |
| INP (Interaction to Next Paint) | Responsiveness — how fast the page responds to input | < 200ms | 200–500ms | > 500ms |
| CLS (Cumulative Layout Shift) | Visual stability — how much the page jumps around | < 0.1 | 0.1–0.25 | > 0.25 |
Measuring performance
# Next.js built-in analytics
# Add to next.config.js:
# experimental: { webVitals: true }
# Or use the web-vitals library
npm install web-vitals
// app/layout.tsx — report Web Vitals
export function reportWebVitals(metric: {
id: string
name: string
value: number
}) {
console.log(metric) // or send to analytics
}
Browser tools:
- Lighthouse (Chrome DevTools > Lighthouse tab) — synthetic lab test
- Chrome DevTools Performance tab — detailed trace of what's happening
- PageSpeed Insights — real-world field data from Chrome users
Image Optimization
Images are typically the largest elements on a page and the biggest opportunity for improvement. Next.js has a built-in Image component that handles optimization automatically.
Use next/image
import Image from 'next/image'
// Local images — automatically optimized at build time
import heroImage from '@/public/hero.jpg'
export function Hero() {
return (
<Image
src={heroImage}
alt="Product dashboard showing analytics"
priority // LCP image — preload it
placeholder="blur" // show blur while loading (local images only)
className="rounded-lg"
/>
)
}
// Remote images — optimized on-demand
export function Avatar({ user }: { user: User }) {
return (
<Image
src={user.avatarUrl}
alt={`${user.name}'s avatar`}
width={40}
height={40}
className="rounded-full"
/>
)
}
Key rules
Always set
priorityon the LCP image — the hero image, main product photo, or whatever is the largest visible element above the fold. This tells Next.js to preload it.Always provide width and height (or use
fill) — this prevents layout shift (CLS) because the browser knows the space to reserve before the image loads.Use
fillfor responsive containers:
<div className="relative aspect-video">
<Image
src="/banner.jpg"
alt="Banner"
fill
className="object-cover rounded-lg"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
- Provide
sizeswhen usingfillor responsive layouts — this tells the browser which size to download instead of always fetching the largest version:
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
- Configure remote domains in
next.config.js:
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com' },
],
},
Code Splitting and Lazy Loading
Dynamic imports for heavy components
Don't load components the user might never see (modals, charts, editors) upfront:
import dynamic from 'next/dynamic'
// Heavy chart library — only load when rendered
const Chart = dynamic(() => import('@/components/chart'), {
loading: () => <div className="h-64 bg-gray-100 animate-pulse rounded-lg" />,
})
// Modal — only load when opened
const SettingsModal = dynamic(() => import('@/components/settings-modal'))
export function Dashboard() {
const [showSettings, setShowSettings] = useState(false)
return (
<>
<Chart data={chartData} />
<button => setShowSettings(true)}>Settings</button>
{showSettings && <SettingsModal => setShowSettings(false)} />}
</>
)
}
Disable SSR for client-only components
Some libraries (maps, rich text editors, canvas-based tools) crash during server rendering:
const Map = dynamic(() => import('@/components/map'), {
ssr: false,
loading: () => <div className="h-96 bg-gray-100 animate-pulse rounded-lg" />,
})
Route-based splitting
Next.js automatically code-splits by route — each page only loads its own JavaScript. This works out of the box with the App Router.
For large shared components used on only some pages, move them out of the shared layout and import them only in the pages that need them.
Bundle Size
Analyze your bundle
# Install the analyzer
npm install -D @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer(nextConfig)
# Run analysis
ANALYZE=true npm run build
Common bundle bloaters and fixes
| Problem | Fix |
|---|---|
Full lodash imported |
Use lodash/debounce instead of import { debounce } from 'lodash' |
moment.js (330KB) |
Switch to date-fns (tree-shakeable) or native Intl APIs |
| Icon libraries (full set) | Import individual icons: import { Search } from 'lucide-react' |
| Unused dependencies | Run npx depcheck to find them |
| Large date/number formatting | Use native Intl.DateTimeFormat and Intl.NumberFormat |
Tree shaking
ES modules are tree-shakeable — unused exports are removed at build time. Ensure:
- Use
import { specific }notimport * - Check that libraries export ESM (look for
"module"field in their package.json) - Avoid side effects in module scope
Rendering Performance
Avoid unnecessary re-renders
React re-renders a component when its state changes or its parent re-renders. Most re-renders are fine — React is fast. Only optimize when you measure an actual problem.
When to reach for memoization:
// useMemo — expensive computation
const sortedItems = useMemo(
() => items.sort((a, b) => a.name.localeCompare(b.name)),
[items]
)
// useCallback — stable function reference for child components
const handleSelect = useCallback((id: string) => {
setSelectedId(id)
}, [])
// React.memo — skip re-render when props haven't changed
const ExpensiveList = memo(function ExpensiveList({ items }: { items: Item[] }) {
return items.map(item => <ExpensiveRow key={item.id} item={item} />)
})
Don't memoize everything. Memoization has its own cost (memory, comparison checks). Use it when:
- A component renders frequently with the same props
- A computation is genuinely expensive (sorting/filtering large lists, complex calculations)
- A callback is passed to many child components or used in a dependency array
Virtualize long lists
For lists with hundreds or thousands of items, render only what's visible:
npm install @tanstack/react-virtual
"use client"
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
})
return (
<div ref={parentRef} className="h-96 overflow-auto">
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
className="absolute w-full px-4 py-3 border-b"
style={{
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
)
}
Loading Strategies
Streaming with Suspense
Break pages into independent sections that load in parallel. Fast sections appear immediately while slower ones stream in:
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div className="grid grid-cols-12 gap-6 p-6">
{/* Fast — renders immediately */}
<div className="col-span-12">
<h1 className="text-2xl font-bold">Dashboard</h1>
</div>
{/* Each section loads independently */}
<div className="col-span-8">
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</div>
<div className="col-span-4">
<Suspense fallback={<ListSkeleton />}>
<RecentOrders />
</Suspense>
</div>
<div className="col-span-12">
<Suspense fallback={<TableSkeleton />}>
<ActivityTable />
</Suspense>
</div>
</div>
)
}
Prefetching
Next.js prefetches linked routes automatically when <Link> elements are visible in the viewport. For programmatic prefetching:
"use client"
import { useRouter } from 'next/navigation'
function ProductCard({ product }: { product: Product }) {
const router = useRouter()
return (
<div
=> router.prefetch(`/products/${product.id}`)}
className="p-4 border rounded-lg hover:shadow-md transition-shadow cursor-pointer"
=> router.push(`/products/${product.id}`)}
>
{product.name}
</div>
)
}
Fonts
Use next/font to avoid layout shift
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // show fallback font immediately, swap when loaded
variable: '--font-inter',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body className="font-sans">{children}</body>
</html>
)
}
// tailwind.config.js
module.exports = {
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)', ...defaultTheme.fontFamily.sans],
},
},
},
}
next/font self-hosts fonts with zero layout shift — no external requests to Google Fonts, no FOUT/FOIT.
Caching
Next.js caching layers
- Request Memoization — duplicate
fetch()calls in the same render pass are automatically deduplicated - Data Cache —
fetch()responses are cached on the server across requests - Full Route Cache — static routes are pre-rendered at build time
- Router Cache — visited routes are cached in the browser for instant back/forward navigation
Revalidation strategies
// Time-based — revalidate every hour
fetch(url, { next: { revalidate: 3600 } })
// On-demand — revalidate when data changes
// In a Server Action or API route:
import { revalidatePath, revalidateTag } from 'next/cache'
revalidatePath('/products') // revalidate a specific path
revalidateTag('products') // revalidate all fetches tagged 'products'
Static vs dynamic rendering
Pages are statically rendered by default. A page becomes dynamic when it uses:
cookies(),headers(),searchParamsfetch()with{ cache: 'no-store' }export const dynamic = 'force-dynamic'
Keep pages static whenever possible — they're served from CDN and are the fastest option.
Quick Wins Checklist
When a user asks "make my app faster," start with these high-impact items:
- Add
priorityto the LCP image - Use
next/imagefor all images with propersizes - Use
next/fontinstead of<link>to Google Fonts - Dynamic import heavy components (charts, editors, modals)
- Add
loading.tsxskeletons for async pages - Wrap independent data-fetching sections in
<Suspense> - Check bundle with
ANALYZE=true npm run build— look for obvious bloat - Use
revalidateon fetch calls instead ofno-storewhere possible - Virtualize lists with 100+ items
- Ensure no
useEffectfetching in client components — move to server components