---
name: forge-performance
description: Web performance discipline. Core Web Vitals (LCP, INP, CLS), image optimization (modern formats + responsive srcset + lazy loading), JS bundle discipline (code-split + tree-shake), critical CSS, font loading without FOIT/FOUT, prefetching that pays off. Contains paste-ready image and font patterns + bundle audit checklist. Use when building user-facing web UI that should feel fast on real devices.
license: MIT
---

# forge-performance

You are writing web UI that real users will load on real devices over real networks. Default agent-written frontend ships 3MB of JavaScript, 4MB of unoptimized images, and 8 web fonts before the first paint. The Lighthouse score is 38 and the user closes the tab. This skill exists to fix that.

The mental model: **performance budgets exist; meet them.** Core Web Vitals are the public scoreboard. Everything below is how to play.

## Quick reference (the things you must never ship)

1. Original-size `.jpg`/`.png` for any image visible above the fold.
2. `<img>` without `width`/`height` or aspect-ratio (causes CLS).
3. `<img>` above-the-fold with `loading="lazy"` (delays LCP).
4. Importing an entire icon library to use 3 icons.
5. Web font without `font-display: swap` (causes FOIT).
6. JavaScript bundle over 200KB gzipped for a typical page.
7. CSS bundle over 50KB unminified for a typical page.
8. `import * as Module from "lib"` in code (defeats tree-shaking).
9. Synchronous third-party scripts in the `<head>`.
10. `setTimeout(..., 0)` to avoid a layout thrash (use rAF or yield).

## Hard rules

### Core Web Vitals targets

**1. LCP (Largest Contentful Paint) ≤ 2.5s** on 75th-percentile real user. Usually the hero image or hero text.

**2. INP (Interaction to Next Paint) ≤ 200ms.** Worst-case interaction response across the session.

**3. CLS (Cumulative Layout Shift) ≤ 0.1.** Visual stability.

Measure with field data, not just Lighthouse. Lighthouse simulates a slow CPU at fixed bandwidth; real users are more varied.

### Images

**4. Modern formats over JPG/PNG.** AVIF or WebP, falling back to JPG. Saves 30-60% bytes.

```tsx
// reference: <picture> with format fallbacks
<picture>
  <source srcSet="/hero.avif" type="image/avif" />
  <source srcSet="/hero.webp" type="image/webp" />
  <img src="/hero.jpg" alt="..." width={1200} height={800} />
</picture>
```

**5. Responsive `srcset` with `sizes`.** Serve the right resolution for the device.

```tsx
<img
  src="/hero-800.jpg"
  srcSet="/hero-400.jpg 400w, /hero-800.jpg 800w, /hero-1600.jpg 1600w"
  sizes="(max-width: 600px) 100vw, 800px"
  width={800} height={600}
  alt="..."
/>
```

**6. Above-the-fold images: `loading="eager"` (default) + `fetchpriority="high"`.** Below-the-fold: `loading="lazy"`.

```tsx
// hero
<img src="/hero.webp" loading="eager" fetchPriority="high" width={1200} height={800} alt="..." />

// in-content, below fold
<img src="/diagram.webp" loading="lazy" width={800} height={400} alt="..." />
```

**7. Always set `width` and `height` (or `aspect-ratio` CSS).** Prevents CLS as the image loads.

**8. Use a CDN with on-the-fly transforms.** Cloudflare Images, Vercel Image Optimization, ImageKit, Cloudinary. Lets you serve `?w=400&fm=webp&q=80` instead of pre-generating 100 variants.

### Fonts

**9. `font-display: swap` for every web font.** Avoids FOIT (Flash of Invisible Text - blank until font loads).

```css
@font-face {
  font-family: 'Inter';
  src: url('/fonts/Inter.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}
```

**10. Preload critical fonts.** The font used by the LCP element.

```html
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin />
```

**11. Subset fonts.** Latin-only is 1/10th the size of the full Unicode font. Tools: `glyphhanger`, `fonttools`.

**12. Variable fonts > many static fonts.** One variable font file (~120KB) replaces 8 weight/style files (~1MB total).

### JavaScript bundles

**13. Code-split at the route level.** Each route loads only its own code.

```tsx
// Next.js / React Router code-splitting is automatic for dynamic imports
const Settings = lazy(() => import('./Settings'));
```

**14. Lazy-load heavy components below the fold.** A 200KB chart library never loads if the user does not scroll.

```tsx
const Chart = lazy(() => import('react-chartjs-2'));

function Dashboard() {
  return (
    <>
      <Hero />          {/* loads immediately */}
      <Suspense fallback={<ChartSkeleton />}>
        <Chart data={data} />   {/* code split */}
      </Suspense>
    </>
  );
}
```

**15. Tree-shake friendly imports. Never `import *`.**

```ts
// BAD: pulls in the whole library, no tree-shaking
import * as lodash from 'lodash';
const { debounce } = lodash;

// GOOD: only debounce is included in the bundle
import debounce from 'lodash/debounce';

// BEST: ESM-native, automatic tree-shaking
import { debounce } from 'lodash-es';
```

**16. Avoid `moment.js` (250KB). Use `date-fns` (~10KB per used function) or `dayjs` (2KB).**

**17. Bundle audit before every release.** `next build` + check `.next/analyze`. Or `vite-bundle-visualizer`, `webpack-bundle-analyzer`.

```bash
# Next.js
ANALYZE=true npm run build

# Vite
npx vite-bundle-visualizer
```

If your main bundle is >200KB gzipped, find the offender and either lazy-load it or replace it.

### Icons

**18. Tree-shakable icon library.** `lucide-react`, `phosphor-react`, `react-icons` (with named imports). Never import the whole package.

```ts
// BAD: 500KB
import * as Icons from 'lucide-react';

// GOOD: ~2KB total
import { Check, X, Search } from 'lucide-react';
```

### CSS

**19. Critical CSS inline in `<head>`; rest in a stylesheet.** Frameworks like Next.js / Vite handle this; verify it's enabled.

**20. Tailwind purges unused classes at build.** `npx tailwindcss --minify` for production.

**21. Avoid CSS-in-JS that ships its runtime to the client.** styled-components, emotion at runtime mode add ~30KB. Compile-time options (linaria, panda, vanilla-extract) do not.

### Third-party scripts

**22. Defer or async every third-party script.** Synchronous third-party scripts in `<head>` block the parser.

```html
<!-- BAD -->
<script src="https://analytics.example.com/track.js"></script>

<!-- GOOD -->
<script src="https://analytics.example.com/track.js" defer></script>
<script src="https://analytics.example.com/track.js" async></script>
```

**23. Use `next/script` in Next.js with `strategy`.**

```tsx
<Script src="https://analytics.example.com" strategy="afterInteractive" />
```

**24. Sandbox iframes and load on interaction.** Heavy embed (YouTube, Maps, Disqus) - replace with a poster + load on click.

### Preloading and prefetching

**25. Preload only the LCP asset.** Over-preloading is worse than not preloading.

**26. Prefetch the next likely route.** Next.js does this for visible `<Link>`s. Hover-prefetch in others.

**27. Avoid `dns-prefetch` to a hundred domains.** Each DNS lookup has cost.

### Server-side

**28. Cache static assets aggressively.** `Cache-Control: public, max-age=31536000, immutable` for hashed filenames.

**29. Cache API responses where stale-while-revalidate works.** A 5-second cache on a list endpoint can absorb 95% of load.

**30. Streaming SSR + Suspense.** Send the page shell immediately, stream slow sections.

### Measuring

**31. Lighthouse for synthetic. CrUX or Vercel Speed Insights for field data.**

**32. Specific metrics, not just "the score."** Lighthouse score can stay 90 while LCP regresses if everything else compensates.

**33. Set budgets and fail CI on regression.** `lighthouse-ci` integrates with GitHub Actions.

```yaml
- run: lhci autorun --config=./lighthouserc.json
```

## Common AI-output patterns to reject

| Pattern | Why slow | Fix |
| --- | --- | --- |
| `<img src="/hero.jpg">` no width/height | CLS | width + height + aspect-ratio |
| `loading="lazy"` on hero | Delays LCP | `loading="eager" fetchpriority="high"` |
| `<img src="/photo.png">` (huge PNG) | Many MB | AVIF/WebP + srcset |
| `import * as Icons from "lucide"` | 500KB | `import { Check }` |
| `import _ from "lodash"` | 70KB | `lodash-es` named imports |
| `import "moment"` | 250KB | `date-fns` or `dayjs` |
| Web font no `font-display: swap` | FOIT | Add `font-display: swap` |
| No `<link rel="preload">` for hero font | LCP late | Preload it |
| Sync third-party script in `<head>` | Parser blocked | `defer` or `async` |
| 3 CSS files loaded synchronously | Render blocked | One file, inline critical |
| `setTimeout(fn, 0)` to dodge thrash | Wrong primitive | `requestAnimationFrame` or `scheduler.yield()` |
| 30-component page with no route split | Multi-MB bundle | Lazy-load per route |

## Worked example: optimized image hero

```tsx
// HeroImage.tsx
export function HeroImage() {
  return (
    <picture>
      <source
        type="image/avif"
        srcSet="/hero-800.avif 800w, /hero-1600.avif 1600w, /hero-2400.avif 2400w"
        sizes="(max-width: 768px) 100vw, 1200px"
      />
      <source
        type="image/webp"
        srcSet="/hero-800.webp 800w, /hero-1600.webp 1600w, /hero-2400.webp 2400w"
        sizes="(max-width: 768px) 100vw, 1200px"
      />
      <img
        src="/hero-1600.jpg"
        srcSet="/hero-800.jpg 800w, /hero-1600.jpg 1600w, /hero-2400.jpg 2400w"
        sizes="(max-width: 768px) 100vw, 1200px"
        width={1600}
        height={1000}
        alt="..."
        loading="eager"
        fetchPriority="high"
        decoding="async"
      />
    </picture>
  );
}
```

```html
<!-- in <head>, preload the LCP image AND its critical font -->
<link rel="preload" as="image" href="/hero-1600.avif" type="image/avif" media="(min-width: 768px)" />
<link rel="preload" as="image" href="/hero-800.avif" type="image/avif" media="(max-width: 767px)" />
<link rel="preload" as="font" href="/fonts/Inter.woff2" type="font/woff2" crossorigin />
```

What this does right: AVIF first, then WebP, then JPG fallback (rule 4); responsive srcset by viewport (rule 5); explicit width + height prevents CLS (rule 7); `loading="eager"` + `fetchPriority="high"` on the LCP image (rule 6); responsive preload with `media` queries (rule 25); critical font preloaded (rule 10).

## Workflow

When building / auditing performance:

1. **Measure first.** Lighthouse + WebPageTest + CrUX. Know the current LCP, INP, CLS.
2. **Fix LCP first.** Usually images. AVIF/WebP, preload, eager.
3. **Fix CLS second.** Set width/height on every image; reserve space for ads and dynamic content.
4. **Fix INP third.** Find the longest tasks in DevTools Performance panel; break them with `scheduler.yield()` or `requestIdleCallback`.
5. **Audit the bundle.** Find the biggest items; lazy-load, code-split, or replace.
6. **Set budgets in CI.** Block regressions.

## Verification

Manual checklist (Lighthouse + bundle audit):

- [ ] LCP ≤2.5s on 75p mobile.
- [ ] INP ≤200ms.
- [ ] CLS ≤0.1.
- [ ] No image > original-resolution served.
- [ ] All web fonts have `font-display: swap`.
- [ ] Main bundle gzipped ≤200KB.
- [ ] No `import *` from a library.
- [ ] All third-party scripts deferred or async.

## When to skip this skill

- Internal admin tools where users are on fast networks and tolerance for slowness is high.
- Backend-only services with no user-facing UI.
- Static documentation sites (already fast by default).

## Related skills

- [`forge-frontend`](../forge-frontend/SKILL.md) - the taste rules on the same surface.
- [`forge-frontend-nextjs`](../forge-frontend-nextjs/SKILL.md) - Next.js streaming + image optimization.
- [`forge-accessibility`](../forge-accessibility/SKILL.md) - a11y often dovetails with perf (semantic HTML loads faster).
