# Perf Optimizer

> Optimize frontend performance in Next.js/React applications — Core Web Vitals, bundle size reduction, lazy loading, image optimization, code splitting, caching strategies, and rendering performance. Use this skill whenever the user mentions performance, page speed, slow loading, Core Web Vitals, LCP, CLS, INP, bundle size, code splitting, lazy loading, memoization, or wants to make their app faster. Also trigger when the user asks about optimizing images, reducing JavaScript, improving Time to Interactive, or diagnosing rendering bottlenecks.

- Skill: `shiven0504/perf-optimizer` (Agent Skill)
- Install (CLI): `npx skillmds@latest add shiven0504/perf-optimizer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/shiven0504/perf-optimizer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Shiven0504 (https://skillmd.com/u/shiven0504)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/shiven0504/perf-optimizer

---


# 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

```bash
# Next.js built-in analytics
# Add to next.config.js:
# experimental: { webVitals: true }

# Or use the web-vitals library
npm install web-vitals
```

```tsx
// 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

```tsx
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

1. **Always set `priority` on 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.

2. **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.

3. **Use `fill` for responsive containers:**
```tsx
<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>
```

4. **Provide `sizes` when using `fill` or responsive layouts** — this tells the browser which size to download instead of always fetching the largest version:
```tsx
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
```

5. **Configure remote domains** in `next.config.js`:
```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:

```tsx
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 onClick={() => setShowSettings(true)}>Settings</button>
      {showSettings && <SettingsModal onClose={() => setShowSettings(false)} />}
    </>
  )
}
```

### Disable SSR for client-only components

Some libraries (maps, rich text editors, canvas-based tools) crash during server rendering:

```tsx
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

```bash
# 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 }` not `import *`
- 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:**

```tsx
// 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:

```bash
npm install @tanstack/react-virtual
```

```tsx
"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:

```tsx
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:

```tsx
"use client"
import { useRouter } from 'next/navigation'

function ProductCard({ product }: { product: Product }) {
  const router = useRouter()

  return (
    <div
      onMouseEnter={() => router.prefetch(`/products/${product.id}`)}
      className="p-4 border rounded-lg hover:shadow-md transition-shadow cursor-pointer"
      onClick={() => router.push(`/products/${product.id}`)}
    >
      {product.name}
    </div>
  )
}
```

## Fonts

### Use next/font to avoid layout shift

```tsx
// 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>
  )
}
```

```js
// 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

1. **Request Memoization** — duplicate `fetch()` calls in the same render pass are automatically deduplicated
2. **Data Cache** — `fetch()` responses are cached on the server across requests
3. **Full Route Cache** — static routes are pre-rendered at build time
4. **Router Cache** — visited routes are cached in the browser for instant back/forward navigation

### Revalidation strategies

```tsx
// 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()`, `searchParams`
- `fetch()` 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:

1. Add `priority` to the LCP image
2. Use `next/image` for all images with proper `sizes`
3. Use `next/font` instead of `<link>` to Google Fonts
4. Dynamic import heavy components (charts, editors, modals)
5. Add `loading.tsx` skeletons for async pages
6. Wrap independent data-fetching sections in `<Suspense>`
7. Check bundle with `ANALYZE=true npm run build` — look for obvious bloat
8. Use `revalidate` on fetch calls instead of `no-store` where possible
9. Virtualize lists with 100+ items
10. Ensure no `useEffect` fetching in client components — move to server components

