# Performance

> Core Web Vitals optimization and page speed for Next.js. This skill should be used when the user asks about "Core Web Vitals", "page speed", "PageSpeed", "LCP", "INP", "CLS", "Lighthouse score", "performance optimization", "next/image optimization", "next/font", "lazy loading", "code splitting", "bundle size", or needs to improve their site's loading speed and interaction responsiveness.

- Skill: `agents-store/performance` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add agents-store/performance`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agents-store/performance/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- Author: agents-store (https://skillmd.com/u/agents-store)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agents-store/performance

---


# Performance & Core Web Vitals

Core Web Vitals are Google's ranking signals measuring real user experience. Optimize for these three metrics.

## Current Thresholds (2026)

| Metric | Good | Needs Improvement | Poor | What It Measures |
|--------|------|-------------------|------|------------------|
| **LCP** | < 2.5s | 2.5s - 4.0s | > 4.0s | Largest visible content painted |
| **INP** | < 200ms | 200ms - 500ms | > 500ms | Responsiveness to all interactions |
| **CLS** | < 0.1 | 0.1 - 0.25 | > 0.25 | Visual stability (layout shifts) |

**INP replaced FID** in March 2024. INP measures ALL interactions (clicks, taps, keyboard), not just the first one.

## LCP Optimization (Largest Contentful Paint)

LCP measures how quickly the largest above-the-fold element renders.

### Preload LCP Images

```tsx
import Image from 'next/image'

// Mark the hero/banner image as priority — preloads it, disables lazy loading
<Image
  src="/hero.jpg"
  alt="Hero banner"
  width={1200}
  height={630}
  priority                    // Critical: preloads for LCP
  sizes="100vw"               // Full-width image
/>
```

Only one image per page should have `priority` — the LCP element.

### Optimize Image Formats

Configure AVIF (best compression) with WebP fallback:

```ts
// next.config.ts
const nextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    remotePatterns: [
      { protocol: 'https', hostname: 'your-cms.com', pathname: '/assets/**' },
    ],
  },
}
```

### Responsive Images with sizes

Always provide `sizes` — without it, the browser downloads the largest image:

```tsx
// Full-width hero
<Image src="/hero.jpg" alt="Hero" width={1920} height={1080} priority
  sizes="100vw" />

// Grid of 3 columns on desktop, full-width on mobile
<Image src="/card.jpg" alt="Card" width={400} height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" />

// Sidebar image
<Image src="/sidebar.jpg" alt="Sidebar" width={300} height={200}
  sizes="(max-width: 768px) 100vw, 300px" />
```

### Server-Side Render Above the Fold

Use Server Components for LCP content — no hydration delay:

```tsx
// app/page.tsx — Server Component by default
export default async function Home() {
  const hero = await getHeroContent() // Fetched on server, rendered in HTML

  return (
    <section>
      <h1>{hero.title}</h1>
      <Image src={hero.image} alt={hero.title} priority sizes="100vw"
        width={1200} height={630} />
    </section>
  )
}
```

## INP Optimization (Interaction to Next Paint)

INP measures responsiveness across ALL user interactions. Three phases: input delay + processing time + presentation delay.

### Break Long Tasks

Any task >50ms blocks the main thread. Split heavy work:

```tsx
'use client'
import { startTransition } from 'react'

function handleSearch(query: string) {
  // Non-urgent: use startTransition to avoid blocking input
  startTransition(() => {
    setFilteredResults(filterLargeDataset(query))
  })
}
```

### Code Split Heavy Components

```tsx
'use client'
import dynamic from 'next/dynamic'

// Load chart library only when needed — not in initial bundle
const Chart = dynamic(() => import('@/components/chart'), {
  loading: () => <div className="h-64 animate-pulse bg-muted rounded" />,
  ssr: false,  // Chart needs browser APIs
})
```

### Debounce Event Handlers

```tsx
'use client'
import { useDeferredValue, useState } from 'react'

function SearchInput() {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query)

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <SearchResults query={deferredQuery} />
    </>
  )
}
```

### Reduce DOM Size

Target: < 1,500 nodes, depth < 32, < 60 children per parent. For long lists, use virtual scrolling:

```tsx
'use client'
import { useVirtualizer } from '@tanstack/react-virtual'

function VirtualList({ items }: { items: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null)
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
  })

  return (
    <div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div key={virtualItem.key} style={{
            position: 'absolute', top: 0, left: 0, width: '100%',
            height: `${virtualItem.size}px`,
            transform: `translateY(${virtualItem.start}px)`,
          }}>
            {items[virtualItem.index]}
          </div>
        ))}
      </div>
    </div>
  )
}
```

## CLS Optimization (Cumulative Layout Shift)

CLS measures visual stability — elements jumping around as the page loads.

### Always Set Dimensions on Images

```tsx
// Good — dimensions prevent layout shift
<Image src="/photo.jpg" alt="Photo" width={800} height={600} />

// Good — fill with sized parent
<div className="relative aspect-video">
  <Image src="/photo.jpg" alt="Photo" fill sizes="100vw" />
</div>

// Bad — no dimensions → layout shift
<img src="/photo.jpg" alt="Photo" />
```

### Use next/font to Prevent Font Shift

```tsx
// app/layout.tsx
import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',        // Show fallback immediately, swap when loaded
  variable: '--font-sans',
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} antialiased`}>
      <body>{children}</body>
    </html>
  )
}
```

Tailwind CSS integration:
```css
/* globals.css */
@import 'tailwindcss';

@theme inline {
  --font-sans: var(--font-sans);
}
```

`next/font` downloads fonts at build time and self-hosts them — zero external requests, zero FOUT (Flash of Unstyled Text).

### Avoid Content Injection Above Fold

Never insert banners, cookie notices, or ads above existing content. Reserve space with CSS:

```tsx
// Reserve space for a dynamic banner
<div className="min-h-12">
  <Suspense fallback={<div className="h-12" />}>
    <DynamicBanner />
  </Suspense>
</div>
```

## Caching & ISR

### Incremental Static Regeneration

Serve static pages, revalidate in the background:

```tsx
// app/blog/[slug]/page.tsx
export const revalidate = 3600 // Revalidate every hour

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  return <article>{/* content */}</article>
}
```

### On-Demand Revalidation

Revalidate when content changes (via webhook from CMS):

```tsx
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const { path, tag } = await request.json()
  if (path) revalidatePath(path)
  if (tag) revalidateTag(tag)
  return Response.json({ revalidated: true })
}
```

## Measurement Tools

| Tool | Data Type | Use For |
|------|-----------|---------|
| **PageSpeed Insights** | Field + Lab | Quick check of any URL |
| **Google Search Console** | Field | Real user data over time |
| **Lighthouse** (DevTools) | Lab | Detailed development audit |
| **Web Vitals Extension** | Field | Real-time monitoring during dev |
| **`next build --profile`** | Lab | Identify slow build/render paths |

## Quick Wins Checklist

| Action | Metric Impact | Effort |
|--------|---------------|--------|
| Add `priority` to LCP image | LCP -500ms+ | Low |
| Add `sizes` to all images | LCP, bandwidth | Low |
| Use `next/font` | CLS elimination | Low |
| Set image width/height | CLS elimination | Low |
| `dynamic()` for heavy components | INP, bundle size | Medium |
| Enable AVIF format | LCP, bandwidth | Low |
| Use `startTransition` for non-urgent updates | INP | Medium |
| ISR with `revalidate` | TTFB | Medium |

