---
title: "speed skill: fast to load, smooth to use"
description: "Performance and smoothness rules for websites and web apps. Covers Core Web Vitals (LCP, INP, CLS), JS/CSS/font/image budgets, Server Components vs 'use…"
canonical: https://void-design.vercel.app/docs/speed
lastModified: 2026-09-16
---

# speed — fast to load, smooth to use

Targets are for a mid-range phone on Slow 4G (Lighthouse mobile: 150 ms RTT, 1.6 Mbps, 4x CPU).
Verify every claim with `void perf` and `void smooth` against a **production build** (see the `audit` skill).

## The 10 rules that matter most

1. **Server-render by default.** `'use client'` only on leaf components that need state, effects or event handlers. Never on `page.tsx` or `layout.tsx`.
2. **The LCP element is in the initial HTML and visible at first paint.** No `loading="lazy"`, no `opacity:0` entrance, no CSS `background-image`, no `<Suspense>` waiting for data around it.
3. **One high-priority image per route:** `fetchPriority="high"` + `loading="eager"` (or `preload`) on `next/image`. `priority` is deprecated in Next 16. Never use it.
4. **Every `<img>`, `<video>`, `<iframe>`, `<canvas>` has `width`+`height` or `aspect-ratio`.** Late UI (banners, toasts, consent) is `position: fixed` or has reserved `min-height`.
5. **Marketing routes: ≤ 170 KB gzip first-load JS** (Next's own baseline is ~133 KB, so you have ~40 KB). App routes ≤ 300 KB.
6. **Prefer CSS over JS for motion.** Animate only `transform` and `opacity`. If you need Motion, use `LazyMotion` + `m` (+32 KB), never the full `motion` component (+46 KB).
7. **Fonts: ≤ 2 families, ≤ 4 files, `next/font`, variable WOFF2, `subsets: ['latin']`.** No Google Fonts `<link>`, no icon fonts.
8. **Third-party scripts load with `next/script strategy="lazyOnload"`.** Chat widgets and video embeds get a static facade that loads the real thing on click.
9. **No scroll listeners for effects.** Use `IntersectionObserver` or CSS scroll-driven animations. Touch/wheel listeners are `{ passive: true }`. No Lenis or other smooth-scroll libraries.
10. **Every loop stops when unseen.** rAF, intervals and infinite CSS animations pause offscreen (`IntersectionObserver`) and when the tab is hidden (`visibilitychange`). At most one WebGL context per page.

## Budgets

"Gate" = the `void perf` default (`kind: "marketing"` or `"app"` in `void.config.ts`); exceeding it fails the run. "Target" = what a polished marketing page should reach.

| Metric (mobile lab, median of runs) | Marketing gate | Marketing target | App gate | Rule id |
|---|---|---|---|---|
| LCP | ≤ 2.5 s (desktop ≤ 1.5 s) | desktop ≤ 1.2 s | ≤ 2.5 s | `perf/lcp-slow` |
| TBT (lab proxy for INP) | ≤ 200 ms | ≤ 100 ms | ≤ 300 ms | `perf/tbt-high` |
| INP (scripted interactions) | ≤ 200 ms | ≤ 100 ms desktop (unthrottled) | ≤ 200 ms | `smooth/inp-slow` |
| CLS | ≤ 0.1 | ≤ 0.05 after full scroll | ≤ 0.1 | `perf/cls-high`, `smooth/layout-shift-on-scroll` |
| TTFB | ≤ 800 ms | ≤ 600 ms | ≤ 800 ms | `perf/ttfb-slow` |
| JS transfer at load | ≤ 170 KB | ≤ 40 KB above the framework | ≤ 300 KB | `perf/js-budget` |
| CSS transfer | ≤ 25 KB | — | ≤ 40 KB | `perf/css-budget` |
| Fonts | ≤ 120 KB, ≤ 2 families, ≤ 4 files | ≤ 2 files preloaded | same | `perf/font-budget`, `perf/too-many-fonts` |
| Images at load (total) | ≤ 1,000 KB | LCP image ≤ 150 KB | — | `perf/image-budget` |
| HTML document | ≤ 30 KB | — | ≤ 60 KB | — |
| Dropped frames while scrolling | ≤ 5% | 0 ms long-frame blocking | ≤ 5% | `smooth/scroll-jank`, `smooth/long-frames-during-scroll` |
| DOM | ≤ 1,400 elements, depth ≤ 32 | — | same | `perf/dom-size` |
| Lighthouse performance (`--lighthouse`) | ≥ 90 | — | ≥ 80 | `perf/lighthouse-score-low` |

Next's own framework baseline is ~133 KB gzip, so a marketing page has ~40 KB for its own client code. Astro/static pages with no framework runtime: ≤ 50 KB JS (islands only). Override budgets in `void.config.ts` (see the `audit` skill).

## Before adding any client JS, ask in order

1. **Can HTML/CSS do it?** `<details>`, `<dialog>`, `popover`, `:has()`, `@starting-style`, CSS transitions, scroll-snap. Then write zero JS.
2. **Can the server do it?** Markdown, syntax highlighting (Shiki at build time), charts as static SVG, date formatting, icons (lucide in a Server Component costs ~1 KB). Then do it in a Server Component.
3. **Is it needed at first paint?** No: `next/dynamic` (inside a client component) mounted on interaction or visibility.
4. **Is it a third party?** `lazyOnload` or a facade. Never synchronous in `<head>`.
5. **Does it add > 5 KB gzip?** Check the size (bundlephobia, `next experimental-analyze`) and find a lighter option or cut it. Known costs: `three` 140–160 KB, `swiper` ~40 KB, `react-markdown` ~50 KB, Prism/`react-syntax-highlighter` ~250 KB, `lenis` ~10 KB plus a permanent rAF loop.

Banned in `'use client'` modules on marketing routes: `prismjs`, `highlight.js`, `shiki`, `react-markdown`, `remark*`, `rehype*`, `moment`, `chart.js`, `recharts`, `d3`, `framer-motion`, and the `motion` component (`import { motion } from "motion/react"`; use `m` from `motion/react-m` instead).

## Server-first component boundaries

- Push `'use client'` down to the smallest interactive leaf (button, menu, carousel). Pass Server Components into client wrappers as `children`.
- A Server Component imported from a `'use client'` file gets bundled as client code. Import it from a server parent and pass it down.
- Pass only the props the client needs. Whole CMS documents or ASTs get serialized twice: once into the HTML, once into the RSC payload.
- Put slow data behind `<Suspense>` with a skeleton of the **same height**. Keep the hero and h1 outside it.
- Pages that can be static stay static (`○` in `next build`). Use `'use cache'` with `cacheComponents: true` for cached dynamic data.
- Details and code: `references/next-performance.md`. Hook and effect bugs: `references/react-pitfalls.md`.

## LCP recipe

1. Know the LCP element. On marketing pages it is the hero image or the h1. A **typographic hero is cheaper** (it only needs HTML, CSS and the font).
2. Serve it from static or prerendered HTML behind a CDN. TTFB is about 40% of LCP.
3. Image hero: a static import plus `fill sizes="100vw" fetchPriority="high" loading="eager"`, AVIF/WebP, ≤ 150 KB on mobile. Every other image stays lazy with an accurate `sizes`.
4. Don't hide it. An element at `opacity:0` is not an LCP candidate until it becomes visible. Animate secondary elements, or start the hero from a visible state.
5. No render-blocking third parties before it. With `next/font` and `next/image` everything is same-origin, so you need zero `preconnect`s.

Recipes: `references/images-fonts.md`. Ids: `perf/lcp-image-lazy`, `perf/lcp-image-no-priority`, `perf/lcp-background-image`, `perf/image-oversized`, `perf/image-legacy-format`, `perf/render-blocking`.

## INP recipe (every click/tap/key → next frame ≤ 200 ms)

- Ship less JS (above). Hydration cost scales with the size of the client component tree.
- Handlers: do the visual acknowledgement synchronously (toggle a class or pending state), then `await yieldToMain()`, then do the heavy work, analytics or network.
- Use `startTransition` for non-urgent state updates (filters, tab content). Use `useDeferredValue` for expensive renders driven by typing.
- No long task > 50 ms after any interaction. Split loops with `scheduler.yield()` (Safari needs the `setTimeout` fallback).
- Per-frame values (pointer position, scroll progress) never go through React state. Write them to the DOM, a CSS variable on the element itself, or a MotionValue.
- Consider `reactCompiler: true` in `next.config.ts` for app routes with heavy re-renders.

```ts
export const yieldToMain = () =>
  (globalThis as any).scheduler?.yield ? (globalThis as any).scheduler.yield() : new Promise<void>((r) => setTimeout(r, 0));
```

## CLS recipe

- Media: `width`/`height` attributes or `aspect-ratio`. `next/image` with `fill` needs a sized, positioned parent. Id: `perf/images-missing-dimensions`.
- Fonts: `next/font` keeps `adjustFontFallback` on. Outside Next, add a `size-adjust` fallback `@font-face`. `font-display: swap` or `optional`, never `block`.
- Late content: announcement bars are server-rendered; cookie banners and toasts are `fixed`; ad and embed slots have `min-height`; skeletons match the final height within ±8 px.
- Never animate `width`, `height`, `top`, `left`, `margin` or `padding`. For accordions use `grid-template-rows: 0fr → 1fr`.
- `scrollbar-gutter: stable` on `html` so modals and scroll locks don't shift the page.

## Images, fonts, third parties (summary)

- `next.config.ts`: `images: { formats: ['image/avif', 'image/webp'] }`. Next 16 only allows `quality` 75 unless you add values to `images.qualities`.
- Below-the-fold: default lazy + `sizes` (e.g. `sizes="(min-width: 1024px) 33vw, 100vw"`). Decorative images: `alt=""`.
- Video: `poster`, `preload="none"`, no autoplay with sound. YouTube/Vimeo: a thumbnail facade with `aspect-ratio: 16/9` that swaps in the iframe on click.
- Scripts: `beforeInteractive` only for consent or bot detection, `afterInteractive` only for analytics you truly need at load, `lazyOnload` for everything else. `strategy="worker"` does not work in the App Router.
- Fonts: turn `adjustFontFallback` **off** for monospace `next/font/local` (it scales Arial to ~131% and code renders oversized until the swap). Use `preload: false` on decorative or italic faces. Scope display fonts to the route layout that uses them.

## Animation and smoothness

Timing, easing and choreography live in the `motion` skill. The performance rules:
- Only `transform` and `opacity`. `filter`/`clip-path` on small elements only. No `backdrop-filter` over large or scrolling areas (at most one blurred header). No `transition: all`.
- Set `will-change` only while an animation runs, never globally, and on at most 3 elements.
- Scroll effects: CSS `animation-timeline: view()` inside `@supports (animation-timeline: view())`, because Firefox lacks it and the page must look complete without it. Or use `IntersectionObserver`. At most one scroll-scrubbed set piece per page.
- Long pages (> 3 viewports): `content-visibility: auto; contain-intrinsic-size: auto 800px` on repeated below-fold sections. Never on the first viewport.
- Every animation respects `prefers-reduced-motion: reduce` and falls back to a finished static frame. Content is never hidden.
- Details: `references/rendering-smoothness.md`. Ids: `smooth/animate-layout-property`, `smooth/transition-all`, `smooth/will-change-overuse`, `smooth/reduced-motion-ignored`, `smooth/scroll-listener-nonpassive` (list all: `void rules smooth`).

## WebGL / canvas: allowed only if all of these hold

Allowed for a hero or ambient backdrop on marketing pages. In app routes only when the canvas **is** the product.
1. **One live context per page** (two at most). Never inside `.map()` or a list item: browsers cap contexts at ~8–16 and silently kill the oldest. For many instances, draw one static frame or use CSS gradients.
2. **Pause, don't skip.** Cancel the rAF when offscreen (`IntersectionObserver`) or when `document.hidden`. Never pause because the user is idle.
3. **Cap DPR at 1.5** (1 for full-screen backdrops) and fps at 30 for ambient effects. Size backdrops to the viewport (`position: fixed`), not the page.
4. **Reduced motion:** render one static frame and stop.
5. **Cleanup:** cancel the rAF, remove listeners, `getExtension('WEBGL_lose_context')?.loseContext()` on unmount, handle `webglcontextlost`. Update uniforms when props change; don't rebuild the program.
6. **Load after paint:** `next/dynamic(..., { ssr: false })` from a client component, a reserved box with the shader's average color as placeholder, `aria-hidden` + `pointer-events-none` when decorative.
7. Prefer `ogl` (10–14 KB) or raw WebGL2 over `three` (140 KB+) for full-screen quads. Mount persistent backdrops once in the root layout so they don't re-init on every navigation.

Paste-ready hook: `references/rendering-smoothness.md` §8. Ids: `smooth/multiple-webgl-contexts`, `smooth/raf-loop-idle`, `lint/webgl-in-map`, `lint/raf-without-cancel`.

## bfcache and caching

- No `unload` listeners. Use `pagehide`. Add `beforeunload` only while there are unsaved changes. Id: `perf/bfcache-blocked`.
- Hashed assets (`/_next/static/*`): `public, max-age=31536000, immutable` (Next does this). HTML: `no-cache` or CDN `s-maxage` + `stale-while-revalidate`. **Never `immutable` on HTML.** Id: `perf/cache-headers`.
- Compression: `next start` only gzips. Put brotli/zstd at the CDN or proxy (and set `compress: false` when the proxy compresses). Id: `perf/no-text-compression`.
- `output: 'standalone'` doesn't copy `public/` or `.next/static`. Copy them, or fonts and images 404.
- Next App Router: rely on `<Link>` prefetch. Don't add document-wide Speculation Rules `prerender`. Multi-page apps (Astro): `prefetch` at `moderate`.

## Never do

- `'use client'` at the top of a page or layout.
- `priority` on `next/image` (Next 16) · `loading="lazy"` on the hero · a hero as CSS `background-image`.
- An h1 or hero at `initial={{ opacity: 0 }}` waiting for hydration.
- Google Fonts `<link>`/`@import` · icon fonts · more than 2 families.
- `framer-motion`, or `motion.div` without `LazyMotion` · Lenis, locomotive-scroll, GSAP ScrollTrigger pinning.
- Animating `width`/`height`/`top`/`left`/`margin`/`box-shadow` · `transition: all` · permanent `will-change`.
- `setState` on `scroll`/`mousemove`/rAF · `setInterval` or subscriptions in the render body · `window`/`document`/`navigator` at module scope or during render.
- Non-passive `wheel`/`touchstart` listeners on `window`/`document`.
- A WebGL context per card · rAF that keeps running offscreen · canvas at DPR 2+.
- Synchronous third-party scripts in `<head>` · autoplaying background video · chat widgets that load at startup.
- `unload` handlers · `Cache-Control: immutable` on HTML.
- Client-side fetches of rate-limited public APIs (e.g. GitHub stars) on every visit. Fetch on the server with `revalidate`.
- Measuring against `next dev`.

## Verify

1. After each significant edit: `void lint` (seconds). It catches `lint/image-priority-deprecated`, `lint/heavy-import`, `lint/page-level-use-client`, `lint/animate-layout-prop`, `lint/scroll-listener-nonpassive`, `lint/no-reduced-motion`, `lint/timer-in-render`, `lint/browser-global-in-render`, `lint/raf-without-cancel`, `lint/webgl-in-map`, `lint/unload-listener`, `lint/google-fonts-link`, `lint/script-strategy`.
2. Before declaring done: `next build`, then `void perf --start "next start -p 3000" --port 3000` and `void smooth --start "next start -p 3000" --port 3000` (scroll jank, INP of interactions, non-composited animations, idle rAF loops, WebGL context count).
3. Fix in order: errors → budgets → warnings. Look up any id with `void rules <id>`. The full loop is in the `audit` skill.
